@hoverlover/cc-discord 0.1.0
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/.claude/settings.template.json +94 -0
- package/.env.example +41 -0
- package/.env.relay.example +46 -0
- package/.env.worker.example +40 -0
- package/README.md +313 -0
- package/hooks/check-discord-messages.ts +204 -0
- package/hooks/cleanup-attachment.ts +47 -0
- package/hooks/safe-bash.ts +157 -0
- package/hooks/steer-send.ts +108 -0
- package/hooks/track-activity.ts +220 -0
- package/memory/README.md +60 -0
- package/memory/core/MemoryCoordinator.ts +703 -0
- package/memory/core/MemoryStore.ts +72 -0
- package/memory/core/session-key.ts +14 -0
- package/memory/core/types.ts +59 -0
- package/memory/index.ts +19 -0
- package/memory/providers/sqlite/SqliteMemoryStore.ts +838 -0
- package/memory/providers/sqlite/index.ts +1 -0
- package/package.json +45 -0
- package/prompts/autoreply-system.md +32 -0
- package/prompts/channel-system.md +22 -0
- package/prompts/orchestrator-system.md +56 -0
- package/scripts/channel-agent.sh +159 -0
- package/scripts/generate-settings.sh +17 -0
- package/scripts/load-env.sh +79 -0
- package/scripts/migrate-memory-to-channel-keys.ts +148 -0
- package/scripts/orchestrator.sh +325 -0
- package/scripts/parse-claude-stream.ts +349 -0
- package/scripts/start-orchestrator.sh +82 -0
- package/scripts/start-relay.sh +17 -0
- package/scripts/start.sh +175 -0
- package/server/attachment.ts +182 -0
- package/server/busy-notify.ts +69 -0
- package/server/config.ts +121 -0
- package/server/db.ts +249 -0
- package/server/index.ts +311 -0
- package/server/memory.ts +88 -0
- package/server/messages.ts +111 -0
- package/server/trace-thread.ts +340 -0
- package/server/typing.ts +101 -0
- package/tools/memory-inspect.ts +94 -0
- package/tools/memory-smoke.ts +173 -0
- package/tools/send-discord +2 -0
- package/tools/send-discord.ts +82 -0
- package/tools/wait-for-discord-messages +2 -0
- package/tools/wait-for-discord-messages.ts +369 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code hook: deliver unread Discord messages into Claude context.
|
|
4
|
+
*
|
|
5
|
+
* Hook input: snake_case fields (hook_event_name, tool_name, ...)
|
|
6
|
+
* Hook output: camelCase fields (hookEventName, additionalContext)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Suppress Node.js ExperimentalWarning (SQLite) to keep hook output clean
|
|
10
|
+
const _origEmit = process.emit;
|
|
11
|
+
process.emit = function (event: string, ...args: any[]) {
|
|
12
|
+
if (event === "warning" && args[0]?.name === "ExperimentalWarning") return false;
|
|
13
|
+
return _origEmit.call(this, event, ...args) as any;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
import { Database as DatabaseSync } from "bun:sqlite";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { MemoryCoordinator } from "../memory/core/MemoryCoordinator.ts";
|
|
20
|
+
import { buildMemorySessionKey } from "../memory/core/session-key.ts";
|
|
21
|
+
import { SqliteMemoryStore } from "../memory/providers/sqlite/SqliteMemoryStore.ts";
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const ROOT_DIR = process.env.ORCHESTRATOR_DIR || join(__dirname, "..");
|
|
25
|
+
|
|
26
|
+
const agentId = process.env.AGENT_ID || process.env.CLAUDE_AGENT_ID || "claude";
|
|
27
|
+
const sessionId =
|
|
28
|
+
process.env.DISCORD_SESSION_ID || process.env.BROKER_SESSION_ID || process.env.SESSION_ID || "default";
|
|
29
|
+
|
|
30
|
+
const dbPath = join(ROOT_DIR, "data", "messages.db");
|
|
31
|
+
|
|
32
|
+
const noopLogger = {
|
|
33
|
+
log() {},
|
|
34
|
+
warn() {},
|
|
35
|
+
error() {},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function syncRuntimeContext({ hookEvent, hookInput }: { hookEvent: string; hookInput: any }) {
|
|
39
|
+
const memoryDbPath = join(ROOT_DIR, "data", "memory.db");
|
|
40
|
+
const memorySessionKey = buildMemorySessionKey({ sessionId, agentId });
|
|
41
|
+
const runtimeHint = process.env.CLAUDE_RUNTIME_ID || hookInput?.session_id || hookInput?.sessionId || null;
|
|
42
|
+
|
|
43
|
+
let store: SqliteMemoryStore | undefined;
|
|
44
|
+
try {
|
|
45
|
+
store = new SqliteMemoryStore({ dbPath: memoryDbPath, logger: noopLogger });
|
|
46
|
+
const coordinator = new MemoryCoordinator({ store, logger: noopLogger });
|
|
47
|
+
await coordinator.init();
|
|
48
|
+
|
|
49
|
+
if (hookEvent === "SessionStart") {
|
|
50
|
+
return await coordinator.beginNewRuntimeContext({
|
|
51
|
+
sessionKey: memorySessionKey,
|
|
52
|
+
runtimeContextId: runtimeHint ? `${runtimeHint}_start_${Date.now().toString(36)}` : null,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return await coordinator.ensureRuntimeContext({
|
|
57
|
+
sessionKey: memorySessionKey,
|
|
58
|
+
runtimeContextId: runtimeHint,
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
} finally {
|
|
63
|
+
if (store) {
|
|
64
|
+
try {
|
|
65
|
+
await store.close();
|
|
66
|
+
} catch {
|
|
67
|
+
/* ignore */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function buildMemoryContext({ queryText, runtimeState }: { queryText: string; runtimeState: any }) {
|
|
74
|
+
const memoryDbPath = join(ROOT_DIR, "data", "memory.db");
|
|
75
|
+
const memorySessionKey = buildMemorySessionKey({ sessionId, agentId });
|
|
76
|
+
|
|
77
|
+
let store: SqliteMemoryStore | undefined;
|
|
78
|
+
try {
|
|
79
|
+
store = new SqliteMemoryStore({ dbPath: memoryDbPath, logger: noopLogger });
|
|
80
|
+
const coordinator = new MemoryCoordinator({ store, logger: noopLogger });
|
|
81
|
+
await coordinator.init();
|
|
82
|
+
|
|
83
|
+
const packet = await coordinator.assembleContext({
|
|
84
|
+
sessionKey: memorySessionKey,
|
|
85
|
+
queryText,
|
|
86
|
+
runtimeContextId: runtimeState?.runtimeContextId || null,
|
|
87
|
+
runtimeEpoch: runtimeState?.runtimeEpoch || null,
|
|
88
|
+
includeSnapshot: true,
|
|
89
|
+
avoidCurrentRuntime: true,
|
|
90
|
+
activeWindowSize: 12,
|
|
91
|
+
maxCards: 6,
|
|
92
|
+
maxRecallTurns: 8,
|
|
93
|
+
maxTurnScan: 300,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return coordinator.formatContextPacket(packet);
|
|
97
|
+
} catch {
|
|
98
|
+
return "";
|
|
99
|
+
} finally {
|
|
100
|
+
if (store) {
|
|
101
|
+
try {
|
|
102
|
+
await store.close();
|
|
103
|
+
} catch {
|
|
104
|
+
/* ignore */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let hookInput: any;
|
|
111
|
+
try {
|
|
112
|
+
const chunks: Buffer[] = [];
|
|
113
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
114
|
+
const raw = Buffer.concat(chunks).toString();
|
|
115
|
+
hookInput = raw ? JSON.parse(raw) : { hook_event_name: "PostToolUse" };
|
|
116
|
+
} catch {
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const hookEvent = hookInput.hook_event_name || "PostToolUse";
|
|
121
|
+
const runtimeState = await syncRuntimeContext({ hookEvent, hookInput });
|
|
122
|
+
|
|
123
|
+
// Match direct agent IDs, generic "claude", and optionally base role
|
|
124
|
+
const baseRole = agentId.replace(/-\d+$/, "");
|
|
125
|
+
const targets = [...new Set([agentId, baseRole, "claude"])];
|
|
126
|
+
|
|
127
|
+
let db: InstanceType<typeof DatabaseSync>;
|
|
128
|
+
try {
|
|
129
|
+
db = new DatabaseSync(dbPath);
|
|
130
|
+
} catch {
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
db.exec("BEGIN IMMEDIATE");
|
|
136
|
+
|
|
137
|
+
const placeholders = targets.map(() => "?").join(",");
|
|
138
|
+
const rows = db
|
|
139
|
+
.prepare(`
|
|
140
|
+
SELECT id, from_agent, message_type, content
|
|
141
|
+
FROM messages
|
|
142
|
+
WHERE session_id = ?
|
|
143
|
+
AND to_agent IN (${placeholders})
|
|
144
|
+
AND read = 0
|
|
145
|
+
ORDER BY id ASC
|
|
146
|
+
`)
|
|
147
|
+
.all(sessionId, ...targets) as any[];
|
|
148
|
+
|
|
149
|
+
if (rows.length > 0) {
|
|
150
|
+
const ids = rows.map((r: any) => r.id);
|
|
151
|
+
const idPlaceholders = ids.map(() => "?").join(",");
|
|
152
|
+
db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${idPlaceholders})`).run(...ids);
|
|
153
|
+
db.exec("COMMIT");
|
|
154
|
+
|
|
155
|
+
const formatted = rows.map((r: any) => {
|
|
156
|
+
const oneLine = String(r.content).replace(/\r/g, "").replace(/\n/g, " ");
|
|
157
|
+
return `[MESSAGE from ${r.from_agent}] [${r.message_type}]: ${oneLine}`;
|
|
158
|
+
});
|
|
159
|
+
const inboxText = `NEW DISCORD MESSAGE(S): ${formatted.join(" | ")}`;
|
|
160
|
+
|
|
161
|
+
const latestQueryText = String(rows[rows.length - 1]?.content || "");
|
|
162
|
+
const memoryText = await buildMemoryContext({
|
|
163
|
+
queryText: latestQueryText,
|
|
164
|
+
runtimeState,
|
|
165
|
+
});
|
|
166
|
+
const contextText = memoryText ? `${inboxText}\n\n${memoryText}` : inboxText;
|
|
167
|
+
|
|
168
|
+
if (hookEvent === "Stop") {
|
|
169
|
+
process.stdout.write(
|
|
170
|
+
JSON.stringify({
|
|
171
|
+
decision: "block",
|
|
172
|
+
reason: `New Discord messages received. Process these before stopping.\n\n${contextText}`,
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
process.stdout.write(
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
hookSpecificOutput: {
|
|
181
|
+
hookEventName: hookEvent,
|
|
182
|
+
additionalContext: contextText,
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
process.exit(0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
db.exec("COMMIT");
|
|
190
|
+
process.exit(0);
|
|
191
|
+
} catch {
|
|
192
|
+
try {
|
|
193
|
+
db.exec("ROLLBACK");
|
|
194
|
+
} catch {
|
|
195
|
+
/* ignore */
|
|
196
|
+
}
|
|
197
|
+
process.exit(0);
|
|
198
|
+
} finally {
|
|
199
|
+
try {
|
|
200
|
+
db.close();
|
|
201
|
+
} catch {
|
|
202
|
+
/* ignore */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code hook: clean up downloaded Discord attachment files after they are read.
|
|
4
|
+
*
|
|
5
|
+
* Runs on PostToolUse for the Read tool. If the file path is inside
|
|
6
|
+
* /tmp/cc-discord/attachments/, delete it to keep the filesystem clean.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
const ATTACHMENT_DIR = "/tmp/cc-discord/attachments/";
|
|
12
|
+
|
|
13
|
+
let hookInput: any;
|
|
14
|
+
try {
|
|
15
|
+
const chunks: Buffer[] = [];
|
|
16
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
17
|
+
const raw = Buffer.concat(chunks).toString();
|
|
18
|
+
hookInput = raw ? JSON.parse(raw) : {};
|
|
19
|
+
} catch {
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const toolName = hookInput.tool_name || hookInput.toolName || "";
|
|
24
|
+
const toolInput = hookInput.tool_input || hookInput.toolInput || {};
|
|
25
|
+
|
|
26
|
+
// Only act on Read tool calls
|
|
27
|
+
if (toolName !== "Read") {
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const filePath = toolInput.file_path || toolInput.path || "";
|
|
32
|
+
|
|
33
|
+
// Only clean up files in our attachment directory
|
|
34
|
+
if (!filePath.startsWith(ATTACHMENT_DIR)) {
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
if (existsSync(filePath)) {
|
|
40
|
+
unlinkSync(filePath);
|
|
41
|
+
// No console output to keep hook silent
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// fail-open: if we can't delete, the TTL cleanup in the relay will handle it
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
process.exit(0);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse Bash guard.
|
|
4
|
+
*
|
|
5
|
+
* Policies:
|
|
6
|
+
* - Detects risky background execution patterns:
|
|
7
|
+
* 1) Bash tool_input.run_in_background=true
|
|
8
|
+
* 2) standalone '&' operator in command text
|
|
9
|
+
*
|
|
10
|
+
* Behavior is controlled by env vars:
|
|
11
|
+
* - BASH_POLICY_MODE=block|allow (default: block)
|
|
12
|
+
* - ALLOW_BASH_RUN_IN_BACKGROUND=true|false (default: true)
|
|
13
|
+
* - ALLOW_BASH_BACKGROUND_OPS=true|false (default: false)
|
|
14
|
+
* - BASH_POLICY_NOTIFY_ON_BLOCK=true|false (default: true)
|
|
15
|
+
* - BASH_POLICY_NOTIFY_CHANNEL_ID=<discord-channel-id> (optional)
|
|
16
|
+
*
|
|
17
|
+
* When blocked, script exits 2 (Claude Code hook "block" behavior).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function readJsonStdin(): Promise<any> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const chunks: Buffer[] = [];
|
|
23
|
+
process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
24
|
+
process.stdin.on("end", () => {
|
|
25
|
+
try {
|
|
26
|
+
const raw = Buffer.concat(chunks).toString();
|
|
27
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
28
|
+
} catch (err) {
|
|
29
|
+
reject(err);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
process.stdin.on("error", reject);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function asBool(value: unknown, defaultValue: boolean = false): boolean {
|
|
37
|
+
if (value === undefined || value === null || value === "") return defaultValue;
|
|
38
|
+
const normalized = String(value).toLowerCase().trim();
|
|
39
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
40
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
41
|
+
return defaultValue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeMode(value: unknown): "allow" | "block" {
|
|
45
|
+
const v = String(value || "block")
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.trim();
|
|
48
|
+
return v === "allow" ? "allow" : "block";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasStandaloneBackgroundOperator(command: string): boolean {
|
|
52
|
+
// Remove escaped ampersands and logical-and to reduce false positives.
|
|
53
|
+
const reduced = String(command || "")
|
|
54
|
+
.replace(/\\&/g, "")
|
|
55
|
+
.replace(/&&/g, "");
|
|
56
|
+
|
|
57
|
+
// Match standalone job-control '&' (e.g. "cmd &" or "; &").
|
|
58
|
+
return /(^|[\s;|])&($|[\s\n])/m.test(reduced);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function truncateCommand(command: string, max: number = 220): string {
|
|
62
|
+
const clean = String(command || "")
|
|
63
|
+
.replace(/[`\r\n\t]+/g, " ")
|
|
64
|
+
.trim();
|
|
65
|
+
if (clean.length <= max) return clean;
|
|
66
|
+
return `${clean.slice(0, max - 1)}…`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function notifyDiscord({ blocked, reasons, command }: { blocked: boolean; reasons: string[]; command: string }) {
|
|
70
|
+
const notifyEnabled = asBool(process.env.BASH_POLICY_NOTIFY_ON_BLOCK, true);
|
|
71
|
+
if (!notifyEnabled) return;
|
|
72
|
+
|
|
73
|
+
const relayHost = process.env.RELAY_HOST || "127.0.0.1";
|
|
74
|
+
const relayPort = process.env.RELAY_PORT || "3199";
|
|
75
|
+
const relayUrl = process.env.RELAY_URL || `http://${relayHost}:${relayPort}`;
|
|
76
|
+
const apiToken = process.env.RELAY_API_TOKEN || "";
|
|
77
|
+
const channelId = process.env.BASH_POLICY_NOTIFY_CHANNEL_ID || null;
|
|
78
|
+
const fromAgent = process.env.AGENT_ID || process.env.CLAUDE_AGENT_ID || "bash-guard";
|
|
79
|
+
|
|
80
|
+
const status = blocked ? "blocked" : "warning";
|
|
81
|
+
const reasonText = reasons.join("; ");
|
|
82
|
+
const commandText = truncateCommand(command);
|
|
83
|
+
|
|
84
|
+
const content = [
|
|
85
|
+
`⚠️ Bash safety policy ${status} a command.`,
|
|
86
|
+
`Agent: ${fromAgent}`,
|
|
87
|
+
`Reason: ${reasonText}`,
|
|
88
|
+
`Command: ${commandText}`,
|
|
89
|
+
].join("\n");
|
|
90
|
+
|
|
91
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
92
|
+
if (apiToken) headers["x-api-token"] = apiToken;
|
|
93
|
+
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await fetch(`${relayUrl}/api/send`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers,
|
|
101
|
+
body: JSON.stringify({
|
|
102
|
+
content,
|
|
103
|
+
channelId,
|
|
104
|
+
fromAgent: "bash-guard",
|
|
105
|
+
}),
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
// Best-effort notification only.
|
|
110
|
+
} finally {
|
|
111
|
+
clearTimeout(timeout);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const input: any = await readJsonStdin();
|
|
117
|
+
const toolName = input.tool_name || input.toolName;
|
|
118
|
+
if (toolName !== "Bash") {
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const toolInput = input.tool_input || input.toolInput || {};
|
|
123
|
+
const command = String(toolInput.command || "");
|
|
124
|
+
|
|
125
|
+
const mode = normalizeMode(process.env.BASH_POLICY_MODE);
|
|
126
|
+
const allowRunInBackground = asBool(process.env.ALLOW_BASH_RUN_IN_BACKGROUND, true);
|
|
127
|
+
const allowAmpersand = asBool(process.env.ALLOW_BASH_BACKGROUND_OPS, false);
|
|
128
|
+
|
|
129
|
+
const reasons: string[] = [];
|
|
130
|
+
|
|
131
|
+
if ((toolInput.run_in_background === true || toolInput.runInBackground === true) && !allowRunInBackground) {
|
|
132
|
+
reasons.push("run_in_background=true is disabled");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (hasStandaloneBackgroundOperator(command) && !allowAmpersand) {
|
|
136
|
+
reasons.push("standalone & background operator is disabled");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (reasons.length === 0) {
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const blocked = mode !== "allow";
|
|
144
|
+
await notifyDiscord({ blocked, reasons, command });
|
|
145
|
+
|
|
146
|
+
if (blocked) {
|
|
147
|
+
console.error(`Bash safety policy blocked command: ${reasons.join("; ")}`);
|
|
148
|
+
process.exit(2);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
process.exit(0);
|
|
152
|
+
} catch {
|
|
153
|
+
// Fail-open to avoid blocking all Bash calls on hook parse issues.
|
|
154
|
+
process.exit(0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse hook: block send-discord when unread messages exist.
|
|
4
|
+
*
|
|
5
|
+
* When a Discord user sends a follow-up while the agent is composing a reply,
|
|
6
|
+
* this hook intercepts the send-discord Bash call, delivers the new messages
|
|
7
|
+
* as the block reason, and forces the agent to revise its reply.
|
|
8
|
+
*
|
|
9
|
+
* Hook input: snake_case fields (hook_event_name, tool_name, tool_input)
|
|
10
|
+
* Hook output: { "decision": "block", "reason": "..." } or silent exit 0
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Database as DatabaseSync } from "bun:sqlite";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const ROOT_DIR = process.env.ORCHESTRATOR_DIR || join(__dirname, "..");
|
|
19
|
+
|
|
20
|
+
const agentId = process.env.AGENT_ID || process.env.CLAUDE_AGENT_ID || "claude";
|
|
21
|
+
const sessionId =
|
|
22
|
+
process.env.DISCORD_SESSION_ID || process.env.BROKER_SESSION_ID || process.env.SESSION_ID || "default";
|
|
23
|
+
|
|
24
|
+
const dbPath = join(ROOT_DIR, "data", "messages.db");
|
|
25
|
+
|
|
26
|
+
let hookInput: any;
|
|
27
|
+
try {
|
|
28
|
+
const chunks: Buffer[] = [];
|
|
29
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
30
|
+
const raw = Buffer.concat(chunks).toString();
|
|
31
|
+
hookInput = raw ? JSON.parse(raw) : {};
|
|
32
|
+
} catch {
|
|
33
|
+
process.exit(0); // fail-open
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Only intercept Bash calls that run send-discord
|
|
37
|
+
const toolName = hookInput.tool_name || hookInput.toolName;
|
|
38
|
+
if (toolName !== "Bash") process.exit(0);
|
|
39
|
+
|
|
40
|
+
const toolInput = hookInput.tool_input || hookInput.toolInput || {};
|
|
41
|
+
const command = String(toolInput.command || "");
|
|
42
|
+
if (!command.includes("send-discord")) process.exit(0);
|
|
43
|
+
|
|
44
|
+
// Check for unread messages
|
|
45
|
+
const baseRole = agentId.replace(/-\d+$/, "");
|
|
46
|
+
const targets = [...new Set([agentId, baseRole, "claude"])];
|
|
47
|
+
|
|
48
|
+
let db: InstanceType<typeof DatabaseSync>;
|
|
49
|
+
try {
|
|
50
|
+
db = new DatabaseSync(dbPath);
|
|
51
|
+
} catch {
|
|
52
|
+
process.exit(0); // fail-open: can't reach DB, allow send
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
db.exec("BEGIN IMMEDIATE");
|
|
57
|
+
|
|
58
|
+
const placeholders = targets.map(() => "?").join(",");
|
|
59
|
+
const rows = db
|
|
60
|
+
.prepare(
|
|
61
|
+
`SELECT id, from_agent, message_type, content
|
|
62
|
+
FROM messages
|
|
63
|
+
WHERE session_id = ?
|
|
64
|
+
AND to_agent IN (${placeholders})
|
|
65
|
+
AND read = 0
|
|
66
|
+
ORDER BY id ASC`,
|
|
67
|
+
)
|
|
68
|
+
.all(sessionId, ...targets) as any[];
|
|
69
|
+
|
|
70
|
+
if (rows.length === 0) {
|
|
71
|
+
db.exec("COMMIT");
|
|
72
|
+
process.exit(0); // no new messages, allow send
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Mark consumed so check-discord-messages won't double-deliver
|
|
76
|
+
const ids = rows.map((r: any) => r.id);
|
|
77
|
+
const idPlaceholders = ids.map(() => "?").join(",");
|
|
78
|
+
db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${idPlaceholders})`).run(...ids);
|
|
79
|
+
db.exec("COMMIT");
|
|
80
|
+
|
|
81
|
+
const formatted = rows.map((r: any) => {
|
|
82
|
+
const oneLine = String(r.content).replace(/\r/g, "").replace(/\n/g, " ");
|
|
83
|
+
return `[MESSAGE from ${r.from_agent}] [${r.message_type}]: ${oneLine}`;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const reason = [
|
|
87
|
+
"SEND BLOCKED: New messages arrived while you were composing a reply.",
|
|
88
|
+
"Read them carefully, revise your reply to address ALL messages, then re-send.",
|
|
89
|
+
"",
|
|
90
|
+
...formatted,
|
|
91
|
+
].join("\n");
|
|
92
|
+
|
|
93
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason }));
|
|
94
|
+
process.exit(0);
|
|
95
|
+
} catch {
|
|
96
|
+
try {
|
|
97
|
+
db.exec("ROLLBACK");
|
|
98
|
+
} catch {
|
|
99
|
+
/* ignore */
|
|
100
|
+
}
|
|
101
|
+
process.exit(0); // fail-open
|
|
102
|
+
} finally {
|
|
103
|
+
try {
|
|
104
|
+
db.close();
|
|
105
|
+
} catch {
|
|
106
|
+
/* ignore */
|
|
107
|
+
}
|
|
108
|
+
}
|