@teamvibe/poller 0.1.67 → 0.1.68
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/dist/claude-spawner.d.ts +5 -0
- package/dist/claude-spawner.js +68 -2
- package/dist/config.d.ts +4 -0
- package/dist/config.js +2 -1
- package/dist/kb-manager.d.ts +15 -0
- package/dist/kb-manager.js +61 -0
- package/dist/mailbox-manager.d.ts +26 -0
- package/dist/mailbox-manager.js +95 -0
- package/dist/poller.js +12 -3
- package/dist/scripts/add-reaction.d.ts +9 -0
- package/dist/scripts/add-reaction.js +22 -0
- package/dist/scripts/lib/slack-client.d.ts +16 -0
- package/dist/scripts/lib/slack-client.js +48 -0
- package/dist/scripts/read-thread.d.ts +9 -0
- package/dist/scripts/read-thread.js +26 -0
- package/dist/scripts/remove-reaction.d.ts +9 -0
- package/dist/scripts/remove-reaction.js +22 -0
- package/dist/scripts/send-message.d.ts +10 -0
- package/dist/scripts/send-message.js +31 -0
- package/dist/scripts/set-status.d.ts +8 -0
- package/dist/scripts/set-status.js +19 -0
- package/dist/scripts/slack-tool.d.ts +1 -0
- package/dist/scripts/slack-tool.js +155 -0
- package/dist/scripts/upload-snippet.d.ts +8 -0
- package/dist/scripts/upload-snippet.js +21 -0
- package/package.json +1 -1
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -13,5 +13,10 @@ export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: S
|
|
|
13
13
|
newSessionId?: string;
|
|
14
14
|
}>;
|
|
15
15
|
export declare function getActiveProcessCount(): number;
|
|
16
|
+
/**
|
|
17
|
+
* Kill a zombie process for a given thread ID.
|
|
18
|
+
* Returns true if a process was found and killed.
|
|
19
|
+
*/
|
|
20
|
+
export declare function killZombieForThread(threadId: string): boolean;
|
|
16
21
|
export declare function isAtCapacity(): boolean;
|
|
17
22
|
export declare function killAllActiveProcesses(): Promise<void>;
|
package/dist/claude-spawner.js
CHANGED
|
@@ -128,6 +128,14 @@ export function buildPrompt(msg, resumeHint) {
|
|
|
128
128
|
: `Channel ${slack.channel}`;
|
|
129
129
|
prompt += `**Channel:** ${channelType}\n`;
|
|
130
130
|
}
|
|
131
|
+
// Inject human-readable timestamp from Slack message ts
|
|
132
|
+
if (msg.response_context.slack?.message_ts) {
|
|
133
|
+
const msgDate = new Date(parseFloat(msg.response_context.slack.message_ts) * 1000);
|
|
134
|
+
const dayOfWeek = msgDate.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' });
|
|
135
|
+
const dateStr = msgDate.toISOString().slice(0, 10);
|
|
136
|
+
const timeStr = msgDate.toISOString().slice(11, 16);
|
|
137
|
+
prompt += `**Sent at:** ${dayOfWeek}, ${dateStr} ${timeStr} UTC\n`;
|
|
138
|
+
}
|
|
131
139
|
prompt += `\n**Message:**\n${msg.text}`;
|
|
132
140
|
if (msg.attachments.length > 0) {
|
|
133
141
|
prompt += '\n\n**Attachments:**\n';
|
|
@@ -333,8 +341,9 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
333
341
|
},
|
|
334
342
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
335
343
|
});
|
|
336
|
-
// Track active process
|
|
344
|
+
// Track active process by message ID and thread ID
|
|
337
345
|
activeProcesses.set(msg.id, proc);
|
|
346
|
+
activeProcessesByThread.set(msg.thread_id, proc);
|
|
338
347
|
proc.stdin?.write(prompt);
|
|
339
348
|
proc.stdin?.end();
|
|
340
349
|
let stdout = '';
|
|
@@ -342,6 +351,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
342
351
|
let stdoutBuffer = '';
|
|
343
352
|
let metrics;
|
|
344
353
|
let lastActivityTime = Date.now();
|
|
354
|
+
let networkErrorStart = null; // timestamp when continuous network errors began
|
|
345
355
|
proc.stdout.on('data', (data) => {
|
|
346
356
|
const chunk = data.toString();
|
|
347
357
|
stdout += chunk;
|
|
@@ -406,10 +416,21 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
406
416
|
}
|
|
407
417
|
}
|
|
408
418
|
});
|
|
419
|
+
const NETWORK_ERROR_PATTERNS = /ETIMEDOUT|ECONNRESET|ENOTFOUND|ECONNREFUSED|fetch failed|getaddrinfo/i;
|
|
409
420
|
proc.stderr.on('data', (data) => {
|
|
410
421
|
const chunk = data.toString();
|
|
411
422
|
stderr += chunk;
|
|
412
423
|
sessionLog.claude('stderr', chunk.trimEnd());
|
|
424
|
+
// Track continuous network errors for early kill
|
|
425
|
+
if (NETWORK_ERROR_PATTERNS.test(chunk)) {
|
|
426
|
+
if (networkErrorStart === null) {
|
|
427
|
+
networkErrorStart = Date.now();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
// Non-network stderr output resets the counter
|
|
432
|
+
networkErrorStart = null;
|
|
433
|
+
}
|
|
413
434
|
});
|
|
414
435
|
let timedOut = false;
|
|
415
436
|
const timeoutMinutes = Math.round(config.CLAUDE_TIMEOUT_MS / 60000);
|
|
@@ -427,6 +448,21 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
427
448
|
let idleWarned = false;
|
|
428
449
|
const idleWatchdog = setInterval(() => {
|
|
429
450
|
const idleMs = Date.now() - lastActivityTime;
|
|
451
|
+
// Network error watchdog: kill faster if seeing sustained network errors with no productive stdout
|
|
452
|
+
if (networkErrorStart !== null) {
|
|
453
|
+
const networkErrorDuration = Date.now() - networkErrorStart;
|
|
454
|
+
if (networkErrorDuration > config.NETWORK_ERROR_KILL_MS) {
|
|
455
|
+
const errorSec = Math.round(networkErrorDuration / 1000);
|
|
456
|
+
logger.error(`Claude Code has sustained network errors for ${errorSec}s, killing process`);
|
|
457
|
+
emitError('session', `Network errors for ${errorSec}s — killing process (likely network outage)`, {
|
|
458
|
+
context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
|
|
459
|
+
channelId: msg.teamvibe.channelId,
|
|
460
|
+
});
|
|
461
|
+
proc.kill('SIGTERM');
|
|
462
|
+
setTimeout(() => proc.kill('SIGKILL'), 5000);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
430
466
|
if (idleMs > config.IDLE_WATCHDOG_KILL_MS) {
|
|
431
467
|
const idleMin = Math.round(idleMs / 60000);
|
|
432
468
|
logger.error(`Claude Code idle for ${idleMin} minutes, killing process`);
|
|
@@ -452,6 +488,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
452
488
|
clearTimeout(timeout);
|
|
453
489
|
clearInterval(idleWatchdog);
|
|
454
490
|
activeProcesses.delete(msg.id);
|
|
491
|
+
activeProcessesByThread.delete(msg.thread_id);
|
|
455
492
|
const result = {
|
|
456
493
|
success: code === 0,
|
|
457
494
|
output: stdout,
|
|
@@ -470,6 +507,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
470
507
|
clearTimeout(timeout);
|
|
471
508
|
clearInterval(idleWatchdog);
|
|
472
509
|
activeProcesses.delete(msg.id);
|
|
510
|
+
activeProcessesByThread.delete(msg.thread_id);
|
|
473
511
|
emitError('session', `Claude Code process spawn error: ${error.message}`, {
|
|
474
512
|
channelId: msg.teamvibe.channelId,
|
|
475
513
|
context: { threadId: msg.thread_id },
|
|
@@ -491,8 +529,15 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
|
|
|
491
529
|
sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
|
|
492
530
|
retryResult = await runClaudeCode(msg, sessionLog, cwd, sessionId, true, lastMessageTs, onMessageSent, typingIndicator);
|
|
493
531
|
}
|
|
494
|
-
// If session ID is "already in use" (stale lock file), retry with
|
|
532
|
+
// If session ID is "already in use" (stale lock file), kill any zombie and retry with fresh ID
|
|
495
533
|
if (!retryResult.success && sessionId && retryResult.error?.includes('already in use')) {
|
|
534
|
+
// Kill zombie process for this thread if still alive
|
|
535
|
+
const killed = killZombieForThread(msg.thread_id);
|
|
536
|
+
if (killed) {
|
|
537
|
+
sessionLog.info('Killed zombie process before fresh session retry');
|
|
538
|
+
// Give the process a moment to fully exit and release lock files
|
|
539
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
540
|
+
}
|
|
496
541
|
sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
|
|
497
542
|
const { randomUUID } = await import('crypto');
|
|
498
543
|
const freshId = randomUUID();
|
|
@@ -502,9 +547,30 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
|
|
|
502
547
|
return retryResult;
|
|
503
548
|
}
|
|
504
549
|
const activeProcesses = new Map();
|
|
550
|
+
const activeProcessesByThread = new Map();
|
|
505
551
|
export function getActiveProcessCount() {
|
|
506
552
|
return activeProcesses.size;
|
|
507
553
|
}
|
|
554
|
+
/**
|
|
555
|
+
* Kill a zombie process for a given thread ID.
|
|
556
|
+
* Returns true if a process was found and killed.
|
|
557
|
+
*/
|
|
558
|
+
export function killZombieForThread(threadId) {
|
|
559
|
+
const proc = activeProcessesByThread.get(threadId);
|
|
560
|
+
if (!proc || proc.exitCode !== null) {
|
|
561
|
+
activeProcessesByThread.delete(threadId);
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
logger.warn(`Killing zombie process for thread ${threadId} (pid: ${proc.pid})`);
|
|
565
|
+
emitError('session', `Killing zombie process for thread ${threadId}`, { severity: 'warn' });
|
|
566
|
+
proc.kill('SIGTERM');
|
|
567
|
+
setTimeout(() => {
|
|
568
|
+
if (proc.exitCode === null) {
|
|
569
|
+
proc.kill('SIGKILL');
|
|
570
|
+
}
|
|
571
|
+
}, 5000);
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
508
574
|
export function isAtCapacity() {
|
|
509
575
|
return activeProcesses.size >= config.MAX_CONCURRENT_SESSIONS;
|
|
510
576
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
15
15
|
LOCK_RETRY_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
|
|
16
16
|
IDLE_WATCHDOG_WARN_MS: z.ZodDefault<z.ZodNumber>;
|
|
17
17
|
IDLE_WATCHDOG_KILL_MS: z.ZodDefault<z.ZodNumber>;
|
|
18
|
+
NETWORK_ERROR_KILL_MS: z.ZodDefault<z.ZodNumber>;
|
|
18
19
|
TEAMVIBE_DATA_DIR: z.ZodDefault<z.ZodString>;
|
|
19
20
|
BRAINS_PATH: z.ZodDefault<z.ZodString>;
|
|
20
21
|
DEFAULT_BRAIN_PATH: z.ZodDefault<z.ZodString>;
|
|
@@ -41,6 +42,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
41
42
|
LOCK_RETRY_INTERVAL_MS: number;
|
|
42
43
|
IDLE_WATCHDOG_WARN_MS: number;
|
|
43
44
|
IDLE_WATCHDOG_KILL_MS: number;
|
|
45
|
+
NETWORK_ERROR_KILL_MS: number;
|
|
44
46
|
TEAMVIBE_DATA_DIR: string;
|
|
45
47
|
BRAINS_PATH: string;
|
|
46
48
|
DEFAULT_BRAIN_PATH: string;
|
|
@@ -73,6 +75,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
73
75
|
LOCK_RETRY_INTERVAL_MS?: number | undefined;
|
|
74
76
|
IDLE_WATCHDOG_WARN_MS?: number | undefined;
|
|
75
77
|
IDLE_WATCHDOG_KILL_MS?: number | undefined;
|
|
78
|
+
NETWORK_ERROR_KILL_MS?: number | undefined;
|
|
76
79
|
TEAMVIBE_DATA_DIR?: string | undefined;
|
|
77
80
|
BRAINS_PATH?: string | undefined;
|
|
78
81
|
DEFAULT_BRAIN_PATH?: string | undefined;
|
|
@@ -102,6 +105,7 @@ export declare const config: {
|
|
|
102
105
|
LOCK_RETRY_INTERVAL_MS: number;
|
|
103
106
|
IDLE_WATCHDOG_WARN_MS: number;
|
|
104
107
|
IDLE_WATCHDOG_KILL_MS: number;
|
|
108
|
+
NETWORK_ERROR_KILL_MS: number;
|
|
105
109
|
TEAMVIBE_DATA_DIR: string;
|
|
106
110
|
BRAINS_PATH: string;
|
|
107
111
|
DEFAULT_BRAIN_PATH: string;
|
package/dist/config.js
CHANGED
|
@@ -20,10 +20,11 @@ const configSchema = z.object({
|
|
|
20
20
|
VISIBILITY_TIMEOUT_SECONDS: z.coerce.number().default(60),
|
|
21
21
|
HEARTBEAT_INTERVAL_MS: z.coerce.number().default(40000), // 40 seconds
|
|
22
22
|
CLAUDE_TIMEOUT_MS: z.coerce.number().default(3600000), // 60 minutes
|
|
23
|
-
STALE_LOCK_TIMEOUT_MS: z.coerce.number().default(
|
|
23
|
+
STALE_LOCK_TIMEOUT_MS: z.coerce.number().default(900000), // 15 minutes (above idle watchdog kill time of 10 min)
|
|
24
24
|
LOCK_RETRY_INTERVAL_MS: z.coerce.number().default(30000), // 30 seconds
|
|
25
25
|
IDLE_WATCHDOG_WARN_MS: z.coerce.number().default(300000), // 5 minutes
|
|
26
26
|
IDLE_WATCHDOG_KILL_MS: z.coerce.number().default(600000), // 10 minutes
|
|
27
|
+
NETWORK_ERROR_KILL_MS: z.coerce.number().default(120000), // 2 minutes of continuous network errors → kill
|
|
27
28
|
// Paths
|
|
28
29
|
TEAMVIBE_DATA_DIR: z.string().default(DEFAULT_DATA_DIR),
|
|
29
30
|
BRAINS_PATH: z.string().default(''),
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
interface KBConfig {
|
|
2
|
+
kbId: string;
|
|
3
|
+
gitRepoUrl: string;
|
|
4
|
+
branch: string;
|
|
5
|
+
claudePath: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Get or clone KB for a persona. Returns the working directory path.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getKnowledgeBasePath(kb?: KBConfig): Promise<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Ensure base paths exist
|
|
13
|
+
*/
|
|
14
|
+
export declare function ensureDirectories(): Promise<void>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { config } from './config.js';
|
|
6
|
+
import { logger } from './logger.js';
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
// Cooldown tracker per KB
|
|
9
|
+
const lastUpdateTimes = new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Get or clone KB for a persona. Returns the working directory path.
|
|
12
|
+
*/
|
|
13
|
+
export async function getKnowledgeBasePath(kb) {
|
|
14
|
+
if (!kb?.gitRepoUrl)
|
|
15
|
+
return config.DEFAULT_KB_PATH;
|
|
16
|
+
const kbDir = join(config.KB_BASE_PATH, kb.kbId);
|
|
17
|
+
if (!existsSync(kbDir)) {
|
|
18
|
+
logger.info(`Cloning KB ${kb.kbId} from ${kb.gitRepoUrl} (branch: ${kb.branch})...`);
|
|
19
|
+
await execAsync(`git clone --branch ${kb.branch} ${kb.gitRepoUrl} ${kbDir}`);
|
|
20
|
+
logger.info(`KB ${kb.kbId} cloned successfully`);
|
|
21
|
+
}
|
|
22
|
+
else if (config.KB_AUTO_UPDATE) {
|
|
23
|
+
await updateKB(kbDir, kb.kbId, kb.branch);
|
|
24
|
+
}
|
|
25
|
+
return kbDir;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Update a KB repo (respects cooldown)
|
|
29
|
+
*/
|
|
30
|
+
async function updateKB(kbDir, kbId, branch) {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const lastUpdate = lastUpdateTimes.get(kbId) || 0;
|
|
33
|
+
if (now - lastUpdate < config.KB_UPDATE_COOLDOWN_MS) {
|
|
34
|
+
logger.debug(`KB ${kbId} update skipped - cooldown active (${Math.round((config.KB_UPDATE_COOLDOWN_MS - (now - lastUpdate)) / 1000)}s remaining)`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
logger.info(`Updating KB ${kbId} at ${kbDir}...`);
|
|
39
|
+
await execAsync(`git fetch origin ${branch} && git reset --hard origin/${branch}`, {
|
|
40
|
+
cwd: kbDir,
|
|
41
|
+
});
|
|
42
|
+
lastUpdateTimes.set(kbId, Date.now());
|
|
43
|
+
logger.info(`KB ${kbId} updated successfully`);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
47
|
+
logger.error(`Failed to update KB ${kbId}: ${errorMessage}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Ensure base paths exist
|
|
52
|
+
*/
|
|
53
|
+
export async function ensureDirectories() {
|
|
54
|
+
const { mkdirSync } = await import('fs');
|
|
55
|
+
if (!existsSync(config.KB_BASE_PATH)) {
|
|
56
|
+
mkdirSync(config.KB_BASE_PATH, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
if (!existsSync(config.DEFAULT_KB_PATH)) {
|
|
59
|
+
mkdirSync(config.DEFAULT_KB_PATH, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write a new message to the mailbox.
|
|
3
|
+
* Returns the filename of the created message file.
|
|
4
|
+
*/
|
|
5
|
+
export declare function writeMessage(brainPath: string, threadId: string, content: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Pick the oldest message from processing/ (crash recovery) or new/.
|
|
8
|
+
* If found in new/, moves it to processing/.
|
|
9
|
+
* Returns { filename, content } or null if no messages.
|
|
10
|
+
*/
|
|
11
|
+
export declare function pickNextMessage(brainPath: string, threadId: string): {
|
|
12
|
+
filename: string;
|
|
13
|
+
content: string;
|
|
14
|
+
} | null;
|
|
15
|
+
/**
|
|
16
|
+
* Delete a message file from processing/ (marks it as done).
|
|
17
|
+
*/
|
|
18
|
+
export declare function markProcessed(brainPath: string, threadId: string, filename: string): void;
|
|
19
|
+
/**
|
|
20
|
+
* Check if there are unprocessed messages in new/ or processing/.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hasUnprocessed(brainPath: string, threadId: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Get the mailbox new/ directory path for use in Claude's system prompt.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getMailboxNewPath(brainPath: string, threadId: string): string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
const MAILBOX_DIR = '.mailbox';
|
|
5
|
+
const NEW_DIR = 'new';
|
|
6
|
+
const PROCESSING_DIR = 'processing';
|
|
7
|
+
function getMailboxPath(brainPath, threadId) {
|
|
8
|
+
return join(brainPath, MAILBOX_DIR, threadId);
|
|
9
|
+
}
|
|
10
|
+
function ensureMailboxDirs(brainPath, threadId) {
|
|
11
|
+
const mailbox = getMailboxPath(brainPath, threadId);
|
|
12
|
+
mkdirSync(join(mailbox, NEW_DIR), { recursive: true });
|
|
13
|
+
mkdirSync(join(mailbox, PROCESSING_DIR), { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Write a new message to the mailbox.
|
|
17
|
+
* Returns the filename of the created message file.
|
|
18
|
+
*/
|
|
19
|
+
export function writeMessage(brainPath, threadId, content) {
|
|
20
|
+
ensureMailboxDirs(brainPath, threadId);
|
|
21
|
+
const timestamp = process.hrtime.bigint().toString();
|
|
22
|
+
const filename = `${timestamp}.txt`;
|
|
23
|
+
const filepath = join(getMailboxPath(brainPath, threadId), NEW_DIR, filename);
|
|
24
|
+
writeFileSync(filepath, content, 'utf-8');
|
|
25
|
+
logger.info(`[Mailbox] Wrote message: ${filename} to ${getMailboxPath(brainPath, threadId)}/new/ (${content.length} chars, threadId=${threadId})`);
|
|
26
|
+
return filename;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Pick the oldest message from processing/ (crash recovery) or new/.
|
|
30
|
+
* If found in new/, moves it to processing/.
|
|
31
|
+
* Returns { filename, content } or null if no messages.
|
|
32
|
+
*/
|
|
33
|
+
export function pickNextMessage(brainPath, threadId) {
|
|
34
|
+
ensureMailboxDirs(brainPath, threadId);
|
|
35
|
+
const mailbox = getMailboxPath(brainPath, threadId);
|
|
36
|
+
const processingDir = join(mailbox, PROCESSING_DIR);
|
|
37
|
+
const newDir = join(mailbox, NEW_DIR);
|
|
38
|
+
// First check processing/ (crash recovery)
|
|
39
|
+
const processingFiles = readdirSync(processingDir).filter(f => f.endsWith('.txt')).sort();
|
|
40
|
+
if (processingFiles.length > 0) {
|
|
41
|
+
const filename = processingFiles[0];
|
|
42
|
+
const content = readFileSync(join(processingDir, filename), 'utf-8');
|
|
43
|
+
logger.info(`[Mailbox] Resuming processing message: ${filename}`);
|
|
44
|
+
return { filename, content };
|
|
45
|
+
}
|
|
46
|
+
// Then check new/
|
|
47
|
+
const newFiles = readdirSync(newDir).filter(f => f.endsWith('.txt')).sort();
|
|
48
|
+
if (newFiles.length > 0) {
|
|
49
|
+
const filename = newFiles[0];
|
|
50
|
+
const srcPath = join(newDir, filename);
|
|
51
|
+
const dstPath = join(processingDir, filename);
|
|
52
|
+
const content = readFileSync(srcPath, 'utf-8');
|
|
53
|
+
renameSync(srcPath, dstPath);
|
|
54
|
+
logger.info(`[Mailbox] Picked message: ${filename} (moved to processing)`);
|
|
55
|
+
return { filename, content };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Delete a message file from processing/ (marks it as done).
|
|
61
|
+
*/
|
|
62
|
+
export function markProcessed(brainPath, threadId, filename) {
|
|
63
|
+
const filepath = join(getMailboxPath(brainPath, threadId), PROCESSING_DIR, filename);
|
|
64
|
+
try {
|
|
65
|
+
if (existsSync(filepath)) {
|
|
66
|
+
unlinkSync(filepath);
|
|
67
|
+
logger.info(`[Mailbox] Deleted processed message: ${filename}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
logger.warn(`[Mailbox] Failed to delete ${filename}: ${error}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Check if there are unprocessed messages in new/ or processing/.
|
|
76
|
+
*/
|
|
77
|
+
export function hasUnprocessed(brainPath, threadId) {
|
|
78
|
+
const mailbox = getMailboxPath(brainPath, threadId);
|
|
79
|
+
const newDir = join(mailbox, NEW_DIR);
|
|
80
|
+
const processingDir = join(mailbox, PROCESSING_DIR);
|
|
81
|
+
try {
|
|
82
|
+
const newCount = existsSync(newDir) ? readdirSync(newDir).filter(f => f.endsWith('.txt')).length : 0;
|
|
83
|
+
const procCount = existsSync(processingDir) ? readdirSync(processingDir).filter(f => f.endsWith('.txt')).length : 0;
|
|
84
|
+
return (newCount + procCount) > 0;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get the mailbox new/ directory path for use in Claude's system prompt.
|
|
92
|
+
*/
|
|
93
|
+
export function getMailboxNewPath(brainPath, threadId) {
|
|
94
|
+
return join(getMailboxPath(brainPath, threadId), NEW_DIR);
|
|
95
|
+
}
|
package/dist/poller.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { config } from './config.js';
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
3
|
import { pollMessages, deleteMessage, } from './sqs-poller.js';
|
|
4
|
-
import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses } from './claude-spawner.js';
|
|
4
|
+
import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses, killZombieForThread } from './claude-spawner.js';
|
|
5
5
|
import { sendSlackError, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
|
|
6
6
|
import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
|
|
7
7
|
import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
|
|
@@ -25,8 +25,17 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
|
|
|
25
25
|
try {
|
|
26
26
|
let lockResult = await acquireSessionLock(threadId, kbPath);
|
|
27
27
|
if (!lockResult.success) {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// If lock acquisition failed, check for zombie processes holding the lock
|
|
29
|
+
const killed = killZombieForThread(threadId);
|
|
30
|
+
if (killed) {
|
|
31
|
+
sessionLog.info(`Killed zombie for thread ${threadId}, retrying lock acquisition`);
|
|
32
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
33
|
+
lockResult = await acquireSessionLock(threadId, kbPath);
|
|
34
|
+
}
|
|
35
|
+
if (!lockResult.success) {
|
|
36
|
+
sessionLog.info(`Session ${threadId} is locked, inbox message will be picked up by active session`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
30
39
|
}
|
|
31
40
|
const { session, lockToken } = lockResult;
|
|
32
41
|
let currentSessionId = session.session_id || undefined;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Add an emoji reaction to the original message.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node add-reaction.js emoji_name
|
|
7
|
+
* node add-reaction.js --channel C123 --message_ts 123.456 emoji_name
|
|
8
|
+
*/
|
|
9
|
+
import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
|
|
10
|
+
const { channel, messageTs, positional } = parseArgs();
|
|
11
|
+
const emoji = positional[0];
|
|
12
|
+
if (!emoji)
|
|
13
|
+
fail('Emoji name required');
|
|
14
|
+
if (!channel)
|
|
15
|
+
fail('Channel required (--channel or SLACK_CHANNEL env)');
|
|
16
|
+
if (!messageTs)
|
|
17
|
+
fail('Message TS required (--message_ts or SLACK_MESSAGE_TS env)');
|
|
18
|
+
async function main() {
|
|
19
|
+
await slack.reactions.add({ channel: channel, timestamp: messageTs, name: emoji });
|
|
20
|
+
succeed();
|
|
21
|
+
}
|
|
22
|
+
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { WebClient } from '@slack/web-api';
|
|
2
|
+
export declare function fail(error: string): never;
|
|
3
|
+
export declare function succeed(data?: Record<string, unknown>): never;
|
|
4
|
+
export declare const slack: WebClient;
|
|
5
|
+
/**
|
|
6
|
+
* Parse CLI args: extracts known --flags and returns remaining positional args.
|
|
7
|
+
* Supports both --flag value and --flag=value syntax.
|
|
8
|
+
* Falls back to env vars for channel/thread_ts/message_ts.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseArgs(argv?: string[]): {
|
|
11
|
+
channel: string | undefined;
|
|
12
|
+
threadTs: string | undefined;
|
|
13
|
+
messageTs: string | undefined;
|
|
14
|
+
positional: string[];
|
|
15
|
+
textFromFlag: string | undefined;
|
|
16
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { WebClient } from '@slack/web-api';
|
|
2
|
+
const BOT_TOKEN = process.env['SLACK_BOT_TOKEN'];
|
|
3
|
+
export function fail(error) {
|
|
4
|
+
console.log(JSON.stringify({ ok: false, error }));
|
|
5
|
+
process.exit(1);
|
|
6
|
+
}
|
|
7
|
+
export function succeed(data = {}) {
|
|
8
|
+
console.log(JSON.stringify({ ok: true, ...data }));
|
|
9
|
+
process.exit(0);
|
|
10
|
+
}
|
|
11
|
+
if (!BOT_TOKEN)
|
|
12
|
+
fail('SLACK_BOT_TOKEN not set');
|
|
13
|
+
export const slack = new WebClient(BOT_TOKEN);
|
|
14
|
+
/**
|
|
15
|
+
* Parse CLI args: extracts known --flags and returns remaining positional args.
|
|
16
|
+
* Supports both --flag value and --flag=value syntax.
|
|
17
|
+
* Falls back to env vars for channel/thread_ts/message_ts.
|
|
18
|
+
*/
|
|
19
|
+
export function parseArgs(argv = process.argv.slice(2)) {
|
|
20
|
+
const flags = {};
|
|
21
|
+
const positional = [];
|
|
22
|
+
const knownFlags = ['--channel', '--thread_ts', '--thread-ts', '--message_ts', '--message-ts', '--text'];
|
|
23
|
+
let i = 0;
|
|
24
|
+
while (i < argv.length) {
|
|
25
|
+
const arg = argv[i];
|
|
26
|
+
// --flag=value
|
|
27
|
+
const eqMatch = arg.match(/^(--[\w-]+)=(.*)$/);
|
|
28
|
+
if (eqMatch) {
|
|
29
|
+
flags[eqMatch[1]] = eqMatch[2];
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// --flag value
|
|
34
|
+
if (knownFlags.includes(arg) && i + 1 < argv.length) {
|
|
35
|
+
flags[arg] = argv[i + 1];
|
|
36
|
+
i += 2;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
positional.push(arg);
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
// If --text was provided, use it (and any remaining positional after it)
|
|
43
|
+
const textFromFlag = flags['--text'];
|
|
44
|
+
const channel = flags['--channel'] || process.env['SLACK_CHANNEL'];
|
|
45
|
+
const threadTs = flags['--thread_ts'] || flags['--thread-ts'] || process.env['SLACK_THREAD_TS'];
|
|
46
|
+
const messageTs = flags['--message_ts'] || flags['--message-ts'] || process.env['SLACK_MESSAGE_TS'];
|
|
47
|
+
return { channel, threadTs, messageTs, positional, textFromFlag };
|
|
48
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Read thread history from a Slack thread.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node read-thread.js [limit]
|
|
7
|
+
* node read-thread.js --channel C123 --thread_ts 123.456 [limit]
|
|
8
|
+
*/
|
|
9
|
+
import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
|
|
10
|
+
const { channel, threadTs, positional } = parseArgs();
|
|
11
|
+
const limit = parseInt(positional[0] || '20', 10);
|
|
12
|
+
if (!channel)
|
|
13
|
+
fail('Channel required (--channel or SLACK_CHANNEL env)');
|
|
14
|
+
if (!threadTs)
|
|
15
|
+
fail('Thread TS required (--thread_ts or SLACK_THREAD_TS env)');
|
|
16
|
+
async function main() {
|
|
17
|
+
const result = await slack.conversations.replies({ channel: channel, ts: threadTs, limit });
|
|
18
|
+
const messages = (result.messages || []).map((m) => ({
|
|
19
|
+
user: m.user || m.bot_id || 'unknown',
|
|
20
|
+
text: m.text || '',
|
|
21
|
+
ts: m.ts,
|
|
22
|
+
is_bot: Boolean(m.bot_id),
|
|
23
|
+
}));
|
|
24
|
+
succeed({ messages });
|
|
25
|
+
}
|
|
26
|
+
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Remove an emoji reaction from the original message.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node remove-reaction.js emoji_name
|
|
7
|
+
* node remove-reaction.js --channel C123 --message_ts 123.456 emoji_name
|
|
8
|
+
*/
|
|
9
|
+
import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
|
|
10
|
+
const { channel, messageTs, positional } = parseArgs();
|
|
11
|
+
const emoji = positional[0];
|
|
12
|
+
if (!emoji)
|
|
13
|
+
fail('Emoji name required');
|
|
14
|
+
if (!channel)
|
|
15
|
+
fail('Channel required (--channel or SLACK_CHANNEL env)');
|
|
16
|
+
if (!messageTs)
|
|
17
|
+
fail('Message TS required (--message_ts or SLACK_MESSAGE_TS env)');
|
|
18
|
+
async function main() {
|
|
19
|
+
await slack.reactions.remove({ channel: channel, timestamp: messageTs, name: emoji });
|
|
20
|
+
succeed();
|
|
21
|
+
}
|
|
22
|
+
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Send a message to a Slack thread.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node send-message.js "Your message here"
|
|
7
|
+
* node send-message.js --text "Your message here"
|
|
8
|
+
* node send-message.js --channel C123 --thread_ts 123.456 "Your message"
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Send a message to a Slack thread.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node send-message.js "Your message here"
|
|
7
|
+
* node send-message.js --text "Your message here"
|
|
8
|
+
* node send-message.js --channel C123 --thread_ts 123.456 "Your message"
|
|
9
|
+
*/
|
|
10
|
+
import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
|
|
11
|
+
const { channel, threadTs, positional, textFromFlag } = parseArgs();
|
|
12
|
+
const text = textFromFlag || positional.join(' ');
|
|
13
|
+
if (!text)
|
|
14
|
+
fail('Message text required');
|
|
15
|
+
if (!channel)
|
|
16
|
+
fail('Channel required (--channel or SLACK_CHANNEL env)');
|
|
17
|
+
if (!threadTs)
|
|
18
|
+
fail('Thread TS required (--thread_ts or SLACK_THREAD_TS env)');
|
|
19
|
+
async function main() {
|
|
20
|
+
console.error(`[slack] send_message: channel=${channel}, thread_ts=${threadTs}`);
|
|
21
|
+
const result = await slack.chat.postMessage({
|
|
22
|
+
channel: channel,
|
|
23
|
+
thread_ts: threadTs,
|
|
24
|
+
text,
|
|
25
|
+
unfurl_links: false,
|
|
26
|
+
unfurl_media: false,
|
|
27
|
+
});
|
|
28
|
+
console.error(`[slack] posted: ts=${result.ts}`);
|
|
29
|
+
succeed({ ts: result.ts });
|
|
30
|
+
}
|
|
31
|
+
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Set the assistant thread status.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node set-status.js "Status text"
|
|
7
|
+
*/
|
|
8
|
+
import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
|
|
9
|
+
const { channel, threadTs, positional } = parseArgs();
|
|
10
|
+
const status = positional.join(' ');
|
|
11
|
+
if (!channel)
|
|
12
|
+
fail('Channel required');
|
|
13
|
+
if (!threadTs)
|
|
14
|
+
fail('Thread TS required');
|
|
15
|
+
async function main() {
|
|
16
|
+
await slack.apiCall('assistant.threads.setStatus', { channel_id: channel, thread_ts: threadTs, status });
|
|
17
|
+
succeed();
|
|
18
|
+
}
|
|
19
|
+
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { WebClient } from '@slack/web-api';
|
|
2
|
+
const BOT_TOKEN = process.env['SLACK_BOT_TOKEN'];
|
|
3
|
+
const CHANNEL = process.env['SLACK_CHANNEL'];
|
|
4
|
+
const THREAD_TS = process.env['SLACK_THREAD_TS'];
|
|
5
|
+
const MESSAGE_TS = process.env['SLACK_MESSAGE_TS'];
|
|
6
|
+
function fail(error) {
|
|
7
|
+
console.log(JSON.stringify({ ok: false, error }));
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
function succeed(data = {}) {
|
|
11
|
+
console.log(JSON.stringify({ ok: true, ...data }));
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
if (!BOT_TOKEN)
|
|
15
|
+
fail('SLACK_BOT_TOKEN not set');
|
|
16
|
+
const slack = new WebClient(BOT_TOKEN);
|
|
17
|
+
const [, , command, ...args] = process.argv;
|
|
18
|
+
/**
|
|
19
|
+
* Strip --channel, --thread_ts, --text flags that Claude sometimes adds.
|
|
20
|
+
* These values come from env vars, not CLI args.
|
|
21
|
+
*/
|
|
22
|
+
function stripFlags(rawArgs) {
|
|
23
|
+
const flagsToStrip = ['--channel', '--thread_ts', '--thread-ts', '--text'];
|
|
24
|
+
const cleaned = [];
|
|
25
|
+
let i = 0;
|
|
26
|
+
while (i < rawArgs.length) {
|
|
27
|
+
if (flagsToStrip.includes(rawArgs[i])) {
|
|
28
|
+
// If this is --text, everything after the next arg is the message
|
|
29
|
+
if (rawArgs[i] === '--text') {
|
|
30
|
+
return rawArgs.slice(i + 1).join(' ');
|
|
31
|
+
}
|
|
32
|
+
i += 2; // skip flag + value
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
cleaned.push(rawArgs[i]);
|
|
36
|
+
i++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return cleaned.join(' ');
|
|
40
|
+
}
|
|
41
|
+
async function main() {
|
|
42
|
+
switch (command) {
|
|
43
|
+
case 'send_message': {
|
|
44
|
+
const text = stripFlags(args);
|
|
45
|
+
if (!text)
|
|
46
|
+
fail('Message text required');
|
|
47
|
+
if (!CHANNEL)
|
|
48
|
+
fail('SLACK_CHANNEL not set');
|
|
49
|
+
if (!THREAD_TS)
|
|
50
|
+
fail('SLACK_THREAD_TS not set');
|
|
51
|
+
console.error(`[slack-tool] send_message: channel=${CHANNEL}, thread_ts=${THREAD_TS}`);
|
|
52
|
+
const result = await slack.chat.postMessage({
|
|
53
|
+
channel: CHANNEL,
|
|
54
|
+
thread_ts: THREAD_TS,
|
|
55
|
+
text,
|
|
56
|
+
unfurl_links: false,
|
|
57
|
+
unfurl_media: false,
|
|
58
|
+
});
|
|
59
|
+
console.error(`[slack-tool] posted: ts=${result.ts}, thread_ts=${result.message?.thread_ts}`);
|
|
60
|
+
succeed({ ts: result.ts });
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case 'add_reaction': {
|
|
64
|
+
const emoji = args[0];
|
|
65
|
+
if (!emoji)
|
|
66
|
+
fail('Emoji name required');
|
|
67
|
+
if (!CHANNEL)
|
|
68
|
+
fail('SLACK_CHANNEL not set');
|
|
69
|
+
if (!MESSAGE_TS)
|
|
70
|
+
fail('SLACK_MESSAGE_TS not set');
|
|
71
|
+
await slack.reactions.add({
|
|
72
|
+
channel: CHANNEL,
|
|
73
|
+
timestamp: MESSAGE_TS,
|
|
74
|
+
name: emoji,
|
|
75
|
+
});
|
|
76
|
+
succeed();
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case 'remove_reaction': {
|
|
80
|
+
const emoji = args[0];
|
|
81
|
+
if (!emoji)
|
|
82
|
+
fail('Emoji name required');
|
|
83
|
+
if (!CHANNEL)
|
|
84
|
+
fail('SLACK_CHANNEL not set');
|
|
85
|
+
if (!MESSAGE_TS)
|
|
86
|
+
fail('SLACK_MESSAGE_TS not set');
|
|
87
|
+
await slack.reactions.remove({
|
|
88
|
+
channel: CHANNEL,
|
|
89
|
+
timestamp: MESSAGE_TS,
|
|
90
|
+
name: emoji,
|
|
91
|
+
});
|
|
92
|
+
succeed();
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case 'read_thread': {
|
|
96
|
+
if (!CHANNEL)
|
|
97
|
+
fail('SLACK_CHANNEL not set');
|
|
98
|
+
if (!THREAD_TS)
|
|
99
|
+
fail('SLACK_THREAD_TS not set');
|
|
100
|
+
const limit = parseInt(args[0] || '20', 10);
|
|
101
|
+
const result = await slack.conversations.replies({
|
|
102
|
+
channel: CHANNEL,
|
|
103
|
+
ts: THREAD_TS,
|
|
104
|
+
limit,
|
|
105
|
+
});
|
|
106
|
+
const messages = (result.messages || []).map((m) => ({
|
|
107
|
+
user: m.user || m.bot_id || 'unknown',
|
|
108
|
+
text: m.text || '',
|
|
109
|
+
ts: m.ts,
|
|
110
|
+
is_bot: Boolean(m.bot_id),
|
|
111
|
+
}));
|
|
112
|
+
succeed({ messages });
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case 'set_status': {
|
|
116
|
+
const status = args.join(' ');
|
|
117
|
+
if (!CHANNEL)
|
|
118
|
+
fail('SLACK_CHANNEL not set');
|
|
119
|
+
if (!THREAD_TS)
|
|
120
|
+
fail('SLACK_THREAD_TS not set');
|
|
121
|
+
await slack.apiCall('assistant.threads.setStatus', {
|
|
122
|
+
channel_id: CHANNEL,
|
|
123
|
+
thread_ts: THREAD_TS,
|
|
124
|
+
status,
|
|
125
|
+
});
|
|
126
|
+
succeed();
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'upload_snippet': {
|
|
130
|
+
const title = args[0];
|
|
131
|
+
const content = args[1];
|
|
132
|
+
const filetype = args[2] || 'text';
|
|
133
|
+
if (!title || !content)
|
|
134
|
+
fail('Title and content required');
|
|
135
|
+
if (!CHANNEL)
|
|
136
|
+
fail('SLACK_CHANNEL not set');
|
|
137
|
+
if (!THREAD_TS)
|
|
138
|
+
fail('SLACK_THREAD_TS not set');
|
|
139
|
+
await slack.filesUploadV2({
|
|
140
|
+
channel_id: CHANNEL,
|
|
141
|
+
thread_ts: THREAD_TS,
|
|
142
|
+
title,
|
|
143
|
+
content,
|
|
144
|
+
filetype,
|
|
145
|
+
});
|
|
146
|
+
succeed();
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
fail(`Unknown command: ${command}. Available: send_message, add_reaction, remove_reaction, read_thread, set_status, upload_snippet`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
main().catch((err) => {
|
|
154
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
155
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Upload a code/text snippet to a Slack thread.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node upload-snippet.js "title" "content" [filetype]
|
|
7
|
+
*/
|
|
8
|
+
import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
|
|
9
|
+
const { channel, threadTs, positional } = parseArgs();
|
|
10
|
+
const [title, content, filetype = 'text'] = positional;
|
|
11
|
+
if (!title || !content)
|
|
12
|
+
fail('Title and content required');
|
|
13
|
+
if (!channel)
|
|
14
|
+
fail('Channel required');
|
|
15
|
+
if (!threadTs)
|
|
16
|
+
fail('Thread TS required');
|
|
17
|
+
async function main() {
|
|
18
|
+
await slack.filesUploadV2({ channel_id: channel, thread_ts: threadTs, title: title, content: content, filetype });
|
|
19
|
+
succeed();
|
|
20
|
+
}
|
|
21
|
+
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|