@teamvibe/poller 0.1.12 → 0.1.14

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/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @teamvibe/poller
2
+
3
+ Self-hosted poller that connects your Slack workspace to Claude Code via TeamVibe.
4
+
5
+ ## Prerequisites
6
+
7
+ - **Node.js** 20+
8
+ - **Claude Code CLI** (`claude`) installed and authenticated
9
+ - A **TeamVibe poller token** (get one from the TeamVibe dashboard)
10
+
11
+ ## Quick Start
12
+
13
+ ### Install as a macOS service (recommended)
14
+
15
+ ```bash
16
+ npx @teamvibe/poller install
17
+ ```
18
+
19
+ The interactive installer will:
20
+ 1. Prompt for your `TEAMVIBE_POLLER_TOKEN`
21
+ 2. Prompt for your `CLAUDE_CODE_OAUTH_TOKEN` (or auto-detect it)
22
+ 3. Configure optional settings (API URL, max concurrent sessions)
23
+ 4. Detect your `claude` CLI path
24
+ 5. Install the package globally (if needed) for a stable service path
25
+ 6. Create `~/.teamvibe/.env` with your configuration
26
+ 7. Install and start a launchd service that runs automatically on login
27
+
28
+ ### Run directly
29
+
30
+ ```bash
31
+ npx @teamvibe/poller
32
+ ```
33
+
34
+ Make sure `~/.teamvibe/.env` exists with at least `TEAMVIBE_POLLER_TOKEN` set.
35
+
36
+ ## Updating
37
+
38
+ ```bash
39
+ npx @teamvibe/poller update
40
+ ```
41
+
42
+ This updates the global installation to the latest version and restarts the service if installed.
43
+
44
+ ## Service Management
45
+
46
+ ```bash
47
+ poller status # Check if the service is running
48
+ poller stop # Stop the service
49
+ poller start # Start the service
50
+ poller restart # Restart the service
51
+ poller uninstall # Remove the service
52
+ ```
53
+
54
+ ## Logs
55
+
56
+ When running as a service, logs are written to:
57
+
58
+ ```
59
+ ~/.teamvibe/logs/poller.stdout.log
60
+ ~/.teamvibe/logs/poller.stderr.log
61
+ ```
62
+
63
+ Follow logs in real-time:
64
+
65
+ ```bash
66
+ tail -f ~/.teamvibe/logs/poller.stdout.log
67
+ ```
68
+
69
+ ## Configuration
70
+
71
+ All configuration is via environment variables (or `~/.teamvibe/.env`):
72
+
73
+ | Variable | Required | Default | Description |
74
+ |----------|----------|---------|-------------|
75
+ | `TEAMVIBE_POLLER_TOKEN` | Yes | — | Your poller authentication token |
76
+ | `CLAUDE_CODE_OAUTH_TOKEN` | No | — | Claude Code OAuth token (from `claude setup-token`) |
77
+ | `TEAMVIBE_API_URL` | No | `https://poller.api.teamvibe.ai` | API endpoint |
78
+ | `MAX_CONCURRENT_SESSIONS` | No | `5` | Max parallel Claude sessions |
79
+ | `CLAUDE_CLI_PATH` | No | `claude` | Path to Claude Code CLI |
80
+ | `TEAMVIBE_DATA_DIR` | No | `~/.teamvibe` | Data directory for brains and state |
81
+
82
+ ## How It Works
83
+
84
+ The poller authenticates with the TeamVibe API using your token, then continuously polls an SQS queue for incoming messages from Slack. When a message arrives, it spawns a Claude Code session with the appropriate brain (knowledge base) and streams the response back to Slack.
@@ -275,20 +275,21 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
275
275
  }
276
276
  export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent) {
277
277
  const result = await runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage, lastMessageTs, onMessageSent);
278
- // If session ID is "already in use" (stale lock file), retry with no session ID (let Claude generate a new one)
279
- if (!result.success && sessionId && result.error?.includes('already in use')) {
280
- sessionLog.info('Session ID already in use (stale lock), retrying without session ID');
281
- const { randomUUID } = await import('crypto');
282
- const freshId = randomUUID();
283
- const retryResult = await runClaudeCode(msg, sessionLog, cwd, freshId, true, lastMessageTs, onMessageSent);
284
- return { ...retryResult, newSessionId: freshId };
285
- }
286
278
  // If --resume failed, retry as a fresh session (session files may have been lost on container restart)
279
+ let retryResult = result;
287
280
  if (!result.success && sessionId && !isFirstMessage) {
288
281
  sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
289
- return runClaudeCode(msg, sessionLog, cwd, sessionId, true, lastMessageTs, onMessageSent);
282
+ retryResult = await runClaudeCode(msg, sessionLog, cwd, sessionId, true, lastMessageTs, onMessageSent);
283
+ }
284
+ // If session ID is "already in use" (stale lock file), retry with a fresh session ID
285
+ if (!retryResult.success && sessionId && retryResult.error?.includes('already in use')) {
286
+ sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
287
+ const { randomUUID } = await import('crypto');
288
+ const freshId = randomUUID();
289
+ const freshResult = await runClaudeCode(msg, sessionLog, cwd, freshId, true, lastMessageTs, onMessageSent);
290
+ return { ...freshResult, newSessionId: freshId };
290
291
  }
291
- return result;
292
+ return retryResult;
292
293
  }
293
294
  const activeProcesses = new Map();
294
295
  export function getActiveProcessCount() {
@@ -0,0 +1 @@
1
+ export declare function handleCommand(command: string): Promise<void>;
@@ -0,0 +1,59 @@
1
+ import { install } from './install.js';
2
+ import { uninstall } from './uninstall.js';
3
+ import { update } from './update.js';
4
+ import { logs } from './logs.js';
5
+ import { start, stop, restart, status } from './service.js';
6
+ function showHelp() {
7
+ console.log(`TeamVibe Poller
8
+
9
+ Usage: poller [command]
10
+
11
+ Commands:
12
+ (no command) Start the poller (default)
13
+ install Install as a macOS launchd service (interactive)
14
+ uninstall Remove the launchd service
15
+ update Update to the latest version and restart
16
+ start Start the installed service
17
+ stop Stop the installed service
18
+ restart Restart the installed service
19
+ status Show service status
20
+ logs Tail service logs
21
+ --help, -h Show this help message
22
+ `);
23
+ }
24
+ export async function handleCommand(command) {
25
+ switch (command) {
26
+ case 'install':
27
+ await install();
28
+ break;
29
+ case 'uninstall':
30
+ await uninstall();
31
+ break;
32
+ case 'update':
33
+ update();
34
+ break;
35
+ case 'logs':
36
+ logs();
37
+ break;
38
+ case 'start':
39
+ start();
40
+ break;
41
+ case 'stop':
42
+ stop();
43
+ break;
44
+ case 'restart':
45
+ restart();
46
+ break;
47
+ case 'status':
48
+ status();
49
+ break;
50
+ case '--help':
51
+ case '-h':
52
+ showHelp();
53
+ break;
54
+ default:
55
+ console.error(`Unknown command: ${command}`);
56
+ showHelp();
57
+ process.exit(1);
58
+ }
59
+ }
@@ -0,0 +1 @@
1
+ export declare function install(): Promise<void>;
@@ -0,0 +1,196 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import { prompt, confirm } from './prompt.js';
5
+ import { generatePlist, PLIST_PATH, TEAMVIBE_DIR, LOGS_DIR, SERVICE_LABEL } from './plist.js';
6
+ function tryGetClaudeSetupToken() {
7
+ try {
8
+ const result = execSync('claude setup-token 2>/dev/null', {
9
+ encoding: 'utf-8',
10
+ timeout: 15000,
11
+ }).trim();
12
+ if (result)
13
+ return result;
14
+ }
15
+ catch {
16
+ // Not available
17
+ }
18
+ return null;
19
+ }
20
+ export async function install() {
21
+ if (process.platform !== 'darwin') {
22
+ console.error('Error: Service installation is only supported on macOS.');
23
+ process.exit(1);
24
+ }
25
+ console.log('TeamVibe Poller - Service Installation\n');
26
+ // Check if already installed
27
+ if (fs.existsSync(PLIST_PATH)) {
28
+ const overwrite = await confirm('Service is already installed. Overwrite?', false);
29
+ if (!overwrite) {
30
+ console.log('Aborted.');
31
+ return;
32
+ }
33
+ // Unload existing service
34
+ try {
35
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' });
36
+ }
37
+ catch {
38
+ // Ignore if not loaded
39
+ }
40
+ }
41
+ // Load existing .env values as defaults
42
+ const existingEnv = loadExistingEnv();
43
+ // Collect configuration
44
+ console.log('Step 1: Poller Token\n');
45
+ console.log(' Get your token from the TeamVibe dashboard (Pollers > Setup Instructions).');
46
+ console.log(' If you lost it, use "Regenerate Token" from the poller menu.\n');
47
+ const token = await prompt('TEAMVIBE_POLLER_TOKEN', existingEnv['TEAMVIBE_POLLER_TOKEN']);
48
+ if (!token) {
49
+ console.error('\nError: TEAMVIBE_POLLER_TOKEN is required.');
50
+ process.exit(1);
51
+ }
52
+ console.log('\nStep 2: Claude Code Authentication\n');
53
+ console.log(' The poller needs Claude Code credentials to run AI sessions.');
54
+ console.log(' Run `claude setup-token` in a terminal to generate a token.\n');
55
+ let claudeOAuthToken = existingEnv['CLAUDE_CODE_OAUTH_TOKEN'] || '';
56
+ const autoToken = tryGetClaudeSetupToken();
57
+ if (autoToken) {
58
+ console.log(' Auto-detected Claude setup token.');
59
+ const useAuto = await confirm(' Use the detected token?');
60
+ if (useAuto) {
61
+ claudeOAuthToken = autoToken;
62
+ }
63
+ }
64
+ if (!claudeOAuthToken) {
65
+ claudeOAuthToken = await prompt('CLAUDE_CODE_OAUTH_TOKEN');
66
+ }
67
+ console.log('\nStep 3: Optional Settings\n');
68
+ const apiUrl = await prompt('TEAMVIBE_API_URL', existingEnv['TEAMVIBE_API_URL'] || 'https://poller.api.teamvibe.ai');
69
+ const maxConcurrent = await prompt('MAX_CONCURRENT_SESSIONS', existingEnv['MAX_CONCURRENT_SESSIONS'] || '5');
70
+ // Resolve claude CLI path
71
+ console.log('\nStep 4: Detecting paths\n');
72
+ let claudePath = '';
73
+ try {
74
+ claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
75
+ }
76
+ catch {
77
+ // Not found
78
+ }
79
+ if (!claudePath) {
80
+ claudePath = await prompt('Path to claude CLI (not found in PATH)');
81
+ if (!claudePath) {
82
+ console.error('Error: claude CLI path is required.');
83
+ process.exit(1);
84
+ }
85
+ }
86
+ else {
87
+ console.log(` Found claude CLI at: ${claudePath}`);
88
+ }
89
+ // Resolve node and poller paths
90
+ const nodePath = process.execPath;
91
+ let pollerPath = path.resolve(process.argv[1] ?? '.');
92
+ // Detect if running via npx (temporary cache path) — need a stable path for launchd
93
+ const isNpx = pollerPath.includes('/_npx/') || pollerPath.includes('\\_npx\\');
94
+ if (isNpx) {
95
+ console.log('\n Detected npx execution — installing globally for a stable service path...');
96
+ try {
97
+ execSync('npm install -g @teamvibe/poller', { stdio: 'inherit' });
98
+ const globalPollerPath = execSync('which poller', { encoding: 'utf-8' }).trim();
99
+ if (globalPollerPath) {
100
+ // Resolve the actual script behind the bin symlink
101
+ const realBin = fs.realpathSync(globalPollerPath);
102
+ pollerPath = realBin;
103
+ console.log(` Installed globally: ${pollerPath}`);
104
+ }
105
+ }
106
+ catch {
107
+ console.error('\n Warning: Could not install globally. The service may not start after reboot.');
108
+ console.error(' Run `npm install -g @teamvibe/poller` manually, then re-run `poller install`.');
109
+ const cont = await confirm(' Continue anyway?', false);
110
+ if (!cont)
111
+ return;
112
+ }
113
+ }
114
+ console.log(` Node path: ${nodePath}`);
115
+ console.log(` Poller path: ${pollerPath}`);
116
+ // Create directories
117
+ fs.mkdirSync(TEAMVIBE_DIR, { recursive: true });
118
+ fs.mkdirSync(LOGS_DIR, { recursive: true });
119
+ // Write .env file
120
+ const envPath = path.join(TEAMVIBE_DIR, '.env');
121
+ const envLines = [
122
+ `TEAMVIBE_POLLER_TOKEN=${token}`,
123
+ `TEAMVIBE_API_URL=${apiUrl}`,
124
+ `MAX_CONCURRENT_SESSIONS=${maxConcurrent}`,
125
+ `CLAUDE_CLI_PATH=${claudePath}`,
126
+ ];
127
+ if (claudeOAuthToken) {
128
+ envLines.push(`CLAUDE_CODE_OAUTH_TOKEN=${claudeOAuthToken}`);
129
+ }
130
+ let writeEnv = true;
131
+ if (fs.existsSync(envPath)) {
132
+ writeEnv = await confirm('\n.env file already exists. Overwrite?', false);
133
+ if (!writeEnv) {
134
+ console.log('Keeping existing .env file.');
135
+ }
136
+ }
137
+ if (writeEnv) {
138
+ fs.writeFileSync(envPath, envLines.join('\n') + '\n');
139
+ console.log(`\nWrote ${envPath}`);
140
+ }
141
+ // Build PATH that includes common locations for claude CLI
142
+ const pathDirs = new Set();
143
+ const claudeDir = path.dirname(claudePath);
144
+ pathDirs.add(claudeDir);
145
+ pathDirs.add('/opt/homebrew/bin');
146
+ pathDirs.add('/usr/local/bin');
147
+ pathDirs.add('/usr/bin');
148
+ pathDirs.add('/bin');
149
+ const plistEnvVars = {
150
+ PATH: Array.from(pathDirs).join(':'),
151
+ };
152
+ // Write plist
153
+ const plistContent = generatePlist(nodePath, pollerPath, plistEnvVars);
154
+ const launchAgentsDir = path.dirname(PLIST_PATH);
155
+ fs.mkdirSync(launchAgentsDir, { recursive: true });
156
+ fs.writeFileSync(PLIST_PATH, plistContent);
157
+ console.log(`Wrote ${PLIST_PATH}`);
158
+ // Load service
159
+ try {
160
+ execSync(`launchctl load "${PLIST_PATH}"`);
161
+ console.log(`\nService '${SERVICE_LABEL}' installed and started.`);
162
+ console.log(`\nUseful commands:`);
163
+ console.log(` poller status - Check service status`);
164
+ console.log(` poller stop - Stop the service`);
165
+ console.log(` poller start - Start the service`);
166
+ console.log(` poller restart - Restart the service`);
167
+ console.log(` poller uninstall - Remove the service`);
168
+ console.log(`\nLogs:`);
169
+ console.log(` tail -f ${path.join(LOGS_DIR, 'poller.stdout.log')}`);
170
+ console.log(` tail -f ${path.join(LOGS_DIR, 'poller.stderr.log')}`);
171
+ }
172
+ catch (error) {
173
+ console.error(`Failed to load service: ${error instanceof Error ? error.message : error}`);
174
+ console.log(`You can manually load it with: launchctl load "${PLIST_PATH}"`);
175
+ }
176
+ }
177
+ function loadExistingEnv() {
178
+ const envPath = path.join(TEAMVIBE_DIR, '.env');
179
+ const result = {};
180
+ try {
181
+ const content = fs.readFileSync(envPath, 'utf-8');
182
+ for (const line of content.split('\n')) {
183
+ const trimmed = line.trim();
184
+ if (!trimmed || trimmed.startsWith('#'))
185
+ continue;
186
+ const eqIdx = trimmed.indexOf('=');
187
+ if (eqIdx > 0) {
188
+ result[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
189
+ }
190
+ }
191
+ }
192
+ catch {
193
+ // No existing file
194
+ }
195
+ return result;
196
+ }
@@ -0,0 +1 @@
1
+ export declare function logs(): void;
@@ -0,0 +1,23 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { spawn } from 'child_process';
4
+ import { LOGS_DIR } from './plist.js';
5
+ export function logs() {
6
+ const stdoutLog = path.join(LOGS_DIR, 'poller.stdout.log');
7
+ const stderrLog = path.join(LOGS_DIR, 'poller.stderr.log');
8
+ const filesToTail = [];
9
+ if (fs.existsSync(stdoutLog))
10
+ filesToTail.push(stdoutLog);
11
+ if (fs.existsSync(stderrLog))
12
+ filesToTail.push(stderrLog);
13
+ if (filesToTail.length === 0) {
14
+ console.error(`No log files found in ${LOGS_DIR}`);
15
+ console.error('Is the service installed? Run `poller install` first.');
16
+ process.exit(1);
17
+ }
18
+ const tail = spawn('tail', ['-f', ...filesToTail], { stdio: 'inherit' });
19
+ process.on('SIGINT', () => {
20
+ tail.kill();
21
+ process.exit(0);
22
+ });
23
+ }
@@ -0,0 +1,5 @@
1
+ export declare const SERVICE_LABEL = "ai.teamvibe.poller";
2
+ export declare const PLIST_PATH: string;
3
+ export declare const TEAMVIBE_DIR: string;
4
+ export declare const LOGS_DIR: string;
5
+ export declare function generatePlist(nodePath: string, pollerPath: string, envVars?: Record<string, string>): string;
@@ -0,0 +1,60 @@
1
+ import * as path from 'path';
2
+ import * as os from 'os';
3
+ export const SERVICE_LABEL = 'ai.teamvibe.poller';
4
+ export const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
5
+ export const TEAMVIBE_DIR = path.join(os.homedir(), '.teamvibe');
6
+ export const LOGS_DIR = path.join(TEAMVIBE_DIR, 'logs');
7
+ export function generatePlist(nodePath, pollerPath, envVars) {
8
+ let envSection = '';
9
+ if (envVars && Object.keys(envVars).length > 0) {
10
+ const entries = Object.entries(envVars)
11
+ .map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`)
12
+ .join('\n');
13
+ envSection = `
14
+ <key>EnvironmentVariables</key>
15
+ <dict>
16
+ ${entries}
17
+ </dict>`;
18
+ }
19
+ return `<?xml version="1.0" encoding="UTF-8"?>
20
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
21
+ <plist version="1.0">
22
+ <dict>
23
+ <key>Label</key>
24
+ <string>${SERVICE_LABEL}</string>
25
+
26
+ <key>ProgramArguments</key>
27
+ <array>
28
+ <string>${escapeXml(nodePath)}</string>
29
+ <string>${escapeXml(pollerPath)}</string>
30
+ </array>
31
+
32
+ <key>WorkingDirectory</key>
33
+ <string>${escapeXml(TEAMVIBE_DIR)}</string>${envSection}
34
+
35
+ <key>KeepAlive</key>
36
+ <true/>
37
+
38
+ <key>RunAtLoad</key>
39
+ <true/>
40
+
41
+ <key>ThrottleInterval</key>
42
+ <integer>10</integer>
43
+
44
+ <key>StandardOutPath</key>
45
+ <string>${escapeXml(path.join(LOGS_DIR, 'poller.stdout.log'))}</string>
46
+
47
+ <key>StandardErrorPath</key>
48
+ <string>${escapeXml(path.join(LOGS_DIR, 'poller.stderr.log'))}</string>
49
+ </dict>
50
+ </plist>
51
+ `;
52
+ }
53
+ function escapeXml(str) {
54
+ return str
55
+ .replace(/&/g, '&amp;')
56
+ .replace(/</g, '&lt;')
57
+ .replace(/>/g, '&gt;')
58
+ .replace(/"/g, '&quot;')
59
+ .replace(/'/g, '&apos;');
60
+ }
@@ -0,0 +1,2 @@
1
+ export declare function prompt(question: string, defaultValue?: string): Promise<string>;
2
+ export declare function confirm(question: string, defaultYes?: boolean): Promise<boolean>;
@@ -0,0 +1,22 @@
1
+ import * as readline from 'readline';
2
+ export function prompt(question, defaultValue) {
3
+ const rl = readline.createInterface({
4
+ input: process.stdin,
5
+ output: process.stdout,
6
+ });
7
+ const suffix = defaultValue ? ` [${defaultValue}]` : '';
8
+ return new Promise((resolve) => {
9
+ rl.question(`${question}${suffix}: `, (answer) => {
10
+ rl.close();
11
+ resolve(answer.trim() || defaultValue || '');
12
+ });
13
+ });
14
+ }
15
+ export function confirm(question, defaultYes = true) {
16
+ const hint = defaultYes ? '[Y/n]' : '[y/N]';
17
+ return prompt(`${question} ${hint}`).then((answer) => {
18
+ if (!answer)
19
+ return defaultYes;
20
+ return answer.toLowerCase().startsWith('y');
21
+ });
22
+ }
@@ -0,0 +1,4 @@
1
+ export declare function start(): void;
2
+ export declare function stop(): void;
3
+ export declare function restart(): void;
4
+ export declare function status(): void;
@@ -0,0 +1,86 @@
1
+ import * as fs from 'fs';
2
+ import { execSync } from 'child_process';
3
+ import { PLIST_PATH, SERVICE_LABEL } from './plist.js';
4
+ function ensureInstalled() {
5
+ if (process.platform !== 'darwin') {
6
+ console.error('Error: Service management is only supported on macOS.');
7
+ process.exit(1);
8
+ }
9
+ if (!fs.existsSync(PLIST_PATH)) {
10
+ console.error('Service is not installed. Run `poller install` first.');
11
+ process.exit(1);
12
+ }
13
+ }
14
+ export function start() {
15
+ ensureInstalled();
16
+ try {
17
+ execSync(`launchctl load "${PLIST_PATH}"`);
18
+ console.log(`Service '${SERVICE_LABEL}' started.`);
19
+ }
20
+ catch (error) {
21
+ console.error(`Failed to start: ${error instanceof Error ? error.message : error}`);
22
+ console.log('The service may already be running. Check with `poller status`.');
23
+ }
24
+ }
25
+ export function stop() {
26
+ ensureInstalled();
27
+ try {
28
+ execSync(`launchctl unload "${PLIST_PATH}"`);
29
+ console.log(`Service '${SERVICE_LABEL}' stopped.`);
30
+ }
31
+ catch (error) {
32
+ console.error(`Failed to stop: ${error instanceof Error ? error.message : error}`);
33
+ }
34
+ }
35
+ export function restart() {
36
+ ensureInstalled();
37
+ try {
38
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' });
39
+ }
40
+ catch {
41
+ // Ignore
42
+ }
43
+ try {
44
+ execSync(`launchctl load "${PLIST_PATH}"`);
45
+ console.log(`Service '${SERVICE_LABEL}' restarted.`);
46
+ }
47
+ catch (error) {
48
+ console.error(`Failed to restart: ${error instanceof Error ? error.message : error}`);
49
+ }
50
+ }
51
+ export function status() {
52
+ if (process.platform !== 'darwin') {
53
+ console.error('Error: Service management is only supported on macOS.');
54
+ process.exit(1);
55
+ }
56
+ if (!fs.existsSync(PLIST_PATH)) {
57
+ console.log('Service is not installed.');
58
+ return;
59
+ }
60
+ try {
61
+ const output = execSync(`launchctl list | grep "${SERVICE_LABEL}"`, {
62
+ encoding: 'utf-8',
63
+ }).trim();
64
+ if (output) {
65
+ const parts = output.split('\t');
66
+ const pid = parts[0];
67
+ const lastExitStatus = parts[1];
68
+ if (pid && pid !== '-') {
69
+ console.log(`Service '${SERVICE_LABEL}' is running (PID: ${pid})`);
70
+ }
71
+ else {
72
+ console.log(`Service '${SERVICE_LABEL}' is loaded but not running`);
73
+ if (lastExitStatus && lastExitStatus !== '0') {
74
+ console.log(`Last exit status: ${lastExitStatus}`);
75
+ }
76
+ }
77
+ }
78
+ else {
79
+ console.log(`Service '${SERVICE_LABEL}' is installed but not loaded.`);
80
+ }
81
+ }
82
+ catch {
83
+ console.log(`Service '${SERVICE_LABEL}' is installed but not loaded.`);
84
+ console.log(`Start it with: poller start`);
85
+ }
86
+ }
@@ -0,0 +1 @@
1
+ export declare function uninstall(): Promise<void>;
@@ -0,0 +1,37 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import { confirm } from './prompt.js';
5
+ import { PLIST_PATH, TEAMVIBE_DIR, SERVICE_LABEL } from './plist.js';
6
+ export async function uninstall() {
7
+ if (process.platform !== 'darwin') {
8
+ console.error('Error: Service management is only supported on macOS.');
9
+ process.exit(1);
10
+ }
11
+ if (!fs.existsSync(PLIST_PATH)) {
12
+ console.log('Service is not installed.');
13
+ return;
14
+ }
15
+ console.log('TeamVibe Poller - Service Uninstall\n');
16
+ // Unload service
17
+ try {
18
+ execSync(`launchctl unload "${PLIST_PATH}"`);
19
+ console.log('Service stopped.');
20
+ }
21
+ catch {
22
+ console.log('Service was not running.');
23
+ }
24
+ // Remove plist
25
+ fs.unlinkSync(PLIST_PATH);
26
+ console.log(`Removed ${PLIST_PATH}`);
27
+ // Optionally remove .env
28
+ const envPath = path.join(TEAMVIBE_DIR, '.env');
29
+ if (fs.existsSync(envPath)) {
30
+ const removeEnv = await confirm('Remove ~/.teamvibe/.env file?', false);
31
+ if (removeEnv) {
32
+ fs.unlinkSync(envPath);
33
+ console.log(`Removed ${envPath}`);
34
+ }
35
+ }
36
+ console.log(`\nService '${SERVICE_LABEL}' has been uninstalled.`);
37
+ }
@@ -0,0 +1 @@
1
+ export declare function update(): void;
@@ -0,0 +1,34 @@
1
+ import * as fs from 'fs';
2
+ import { execSync } from 'child_process';
3
+ import { PLIST_PATH, SERVICE_LABEL } from './plist.js';
4
+ export function update() {
5
+ console.log('TeamVibe Poller - Update\n');
6
+ console.log('Updating @teamvibe/poller...');
7
+ try {
8
+ execSync('npm install -g @teamvibe/poller@latest', { stdio: 'inherit' });
9
+ }
10
+ catch {
11
+ console.error('Failed to update. Try running: npm install -g @teamvibe/poller@latest');
12
+ process.exit(1);
13
+ }
14
+ // Restart the service if installed
15
+ if (process.platform === 'darwin' && fs.existsSync(PLIST_PATH)) {
16
+ console.log(`\nRestarting service '${SERVICE_LABEL}'...`);
17
+ try {
18
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' });
19
+ }
20
+ catch {
21
+ // Ignore
22
+ }
23
+ try {
24
+ execSync(`launchctl load "${PLIST_PATH}"`);
25
+ console.log('Service restarted.');
26
+ }
27
+ catch (error) {
28
+ console.error(`Failed to restart: ${error instanceof Error ? error.message : error}`);
29
+ }
30
+ }
31
+ else {
32
+ console.log('\nUpdate complete. Restart the poller to use the new version.');
33
+ }
34
+ }
package/dist/index.js CHANGED
@@ -1,265 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { config } from './config.js';
3
- import { logger } from './logger.js';
4
- import { pollMessages, deleteMessage, extendVisibility, } from './sqs-poller.js';
5
- import { spawnClaudeCode, isAtCapacity, getActiveProcessCount } from './claude-spawner.js';
6
- import { sendSlackError, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
7
- import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
8
- import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
9
- import { initAuth, stopRefresh } from './auth-provider.js';
10
- logger.info('TeamVibe Poller starting...');
11
- logger.info(` Max concurrent: ${config.MAX_CONCURRENT_SESSIONS}`);
12
- logger.info(` Brains path: ${config.BRAINS_PATH}`);
13
- // Track active message processing
14
- const processingMessages = new Set();
15
- // Per-thread completion signals
16
- const threadCompletionSignals = new Map();
17
- const waitingCountByThread = new Map();
18
- function getQueueStats() {
19
- let totalWaiting = 0;
20
- waitingCountByThread.forEach((count) => {
21
- totalWaiting += count;
22
- });
23
- return {
24
- processing: processingMessages.size,
25
- threadsWithWaiting: waitingCountByThread.size,
26
- totalWaiting,
27
- };
2
+ const command = process.argv[2];
3
+ if (command && ['install', 'uninstall', 'update', 'logs', 'status', 'start', 'stop', 'restart', '--help', '-h'].includes(command)) {
4
+ const { handleCommand } = await import('./cli/commands.js');
5
+ await handleCommand(command);
28
6
  }
29
- function logQueueState(context) {
30
- const stats = getQueueStats();
31
- logger.info(`[Queue] ${context} | Processing: ${stats.processing}, Threads with waiting: ${stats.threadsWithWaiting}, Total waiting: ${stats.totalWaiting}`);
7
+ else {
8
+ const { startPoller } = await import('./poller.js');
9
+ await startPoller();
32
10
  }
33
- function waitForThreadCompletion(threadId) {
34
- return new Promise((resolve) => {
35
- const signals = threadCompletionSignals.get(threadId) || [];
36
- signals.push(resolve);
37
- threadCompletionSignals.set(threadId, signals);
38
- const currentCount = waitingCountByThread.get(threadId) || 0;
39
- waitingCountByThread.set(threadId, currentCount + 1);
40
- logger.info(`[Queue] Thread ${threadId}: ${currentCount + 1} message(s) now waiting`);
41
- logQueueState('After enqueue');
42
- });
43
- }
44
- function signalThreadCompletion(threadId) {
45
- const signals = threadCompletionSignals.get(threadId);
46
- const waitingCount = signals?.length || 0;
47
- if (signals && signals.length > 0) {
48
- logger.info(`[Queue] Thread ${threadId} completed, waking ${waitingCount} waiting message(s)`);
49
- signals.forEach((resolve) => resolve());
50
- threadCompletionSignals.delete(threadId);
51
- waitingCountByThread.delete(threadId);
52
- logQueueState('After signal');
53
- }
54
- }
55
- function startHeartbeat(receiptHandle, sessionLog) {
56
- return setInterval(async () => {
57
- try {
58
- await extendVisibility(receiptHandle, config.VISIBILITY_TIMEOUT_SECONDS);
59
- sessionLog.info(`Heartbeat: extended visibility by ${config.VISIBILITY_TIMEOUT_SECONDS}s`);
60
- }
61
- catch (error) {
62
- sessionLog.error(`Heartbeat failed: ${error instanceof Error ? error.message : error}`);
63
- }
64
- }, config.HEARTBEAT_INTERVAL_MS);
65
- }
66
- async function processMessage(received) {
67
- const { queueMessage, receiptHandle, messageId } = received;
68
- const logSessionId = messageId.slice(0, 8);
69
- const threadId = queueMessage.thread_id;
70
- const sessionLog = logger.createSession(logSessionId);
71
- // Fetch user info if we only have ID
72
- if (queueMessage.source !== 'cron' &&
73
- queueMessage.sender.name === 'Unknown User' &&
74
- queueMessage.sender.id !== 'unknown') {
75
- const userInfo = await getUserInfo(queueMessage.teamvibe.botToken, queueMessage.sender.id);
76
- queueMessage.sender.name = userInfo.realName;
77
- sessionLog.info(`Resolved user: ${userInfo.realName} (@${userInfo.name})`);
78
- }
79
- sessionLog.info(`Processing message from ${queueMessage.sender.name} (${queueMessage.sender.id})`);
80
- sessionLog.info(`Thread: ${threadId}`);
81
- sessionLog.info(`Brain: ${queueMessage.teamvibe.brain?.brainId ?? 'none'}`);
82
- sessionLog.info(`Type: ${queueMessage.type}`);
83
- sessionLog.info(`Log file: ${sessionLog.getLogFile()}`);
84
- const hasSlackContext = Boolean(queueMessage.response_context.slack?.channel &&
85
- queueMessage.response_context.slack?.message_ts);
86
- // Get brain path for this channel
87
- let kbPath;
88
- try {
89
- kbPath = await getBrainPath(queueMessage.teamvibe.brain);
90
- }
91
- catch (error) {
92
- const errorMessage = error instanceof Error ? error.message : String(error);
93
- sessionLog.error(`Failed to clone brain: ${errorMessage}`);
94
- if (hasSlackContext) {
95
- try {
96
- await sendSlackError(queueMessage, `Failed to clone brain repository: ${errorMessage}`);
97
- await addReaction(queueMessage, 'x');
98
- }
99
- catch (slackError) {
100
- sessionLog.error(`Failed to send Slack error: ${slackError}`);
101
- }
102
- }
103
- await deleteMessage(receiptHandle);
104
- return;
105
- }
106
- // Try to acquire session lock
107
- let lockResult = await acquireSessionLock(threadId, kbPath);
108
- if (!lockResult.success) {
109
- sessionLog.info(`Session ${threadId} is processing, waiting for completion...`);
110
- const waitHeartbeat = startHeartbeat(receiptHandle, sessionLog);
111
- try {
112
- await waitForThreadCompletion(threadId);
113
- sessionLog.info(`Thread ${threadId} completed, retrying lock acquisition`);
114
- lockResult = await acquireSessionLock(threadId, kbPath);
115
- if (!lockResult.success) {
116
- sessionLog.info('Lock still held, re-queuing for next completion');
117
- clearInterval(waitHeartbeat);
118
- return processMessage(received);
119
- }
120
- }
121
- finally {
122
- clearInterval(waitHeartbeat);
123
- }
124
- }
125
- const { session, lockToken } = lockResult;
126
- const isFirstMessage = session.message_count === 1;
127
- sessionLog.info(`Acquired lock on session ${threadId}, Claude session: ${session.session_id}, mode: ${isFirstMessage ? 'new' : 'resume'}, message #${session.message_count}`);
128
- logQueueState(`Lock acquired for ${threadId}`);
129
- processingMessages.add(messageId);
130
- const heartbeat = startHeartbeat(receiptHandle, sessionLog);
131
- // Start typing indicator
132
- const stopTyping = hasSlackContext
133
- ? startTypingIndicator(queueMessage)
134
- : undefined;
135
- try {
136
- const result = await spawnClaudeCode(queueMessage, sessionLog, kbPath, session.session_id || undefined, isFirstMessage, session.last_message_ts, () => stopTyping?.());
137
- stopTyping?.();
138
- // If Claude generated a new session ID (stale lock recovery), persist it
139
- if (result.newSessionId && lockToken) {
140
- await updateSessionId(threadId, lockToken, result.newSessionId);
141
- }
142
- if (result.success) {
143
- sessionLog.info('Claude Code completed successfully');
144
- // Push any changes in the channel brain repo
145
- if (queueMessage.teamvibe.brain?.brainId) {
146
- await pushBrainChanges(kbPath, queueMessage.teamvibe.brain.brainId);
147
- }
148
- if (lockToken) {
149
- const lastMessageTs = queueMessage.response_context.slack?.message_ts;
150
- await releaseSessionLock(threadId, lockToken, 'idle', lastMessageTs);
151
- }
152
- }
153
- else {
154
- sessionLog.error(`Claude Code failed: ${result.error}`);
155
- if (hasSlackContext) {
156
- await sendSlackError(queueMessage, result.error || `Process exited with code ${result.exitCode}`);
157
- await addReaction(queueMessage, 'x');
158
- }
159
- if (lockToken) {
160
- await releaseSessionLock(threadId, lockToken, 'idle');
161
- }
162
- }
163
- await deleteMessage(receiptHandle);
164
- sessionLog.info('Message processed and deleted');
165
- }
166
- catch (error) {
167
- stopTyping?.();
168
- sessionLog.error(`Error processing message: ${error instanceof Error ? error.message : error}`);
169
- if (lockToken) {
170
- try {
171
- await releaseSessionLock(threadId, lockToken, 'idle');
172
- }
173
- catch (releaseError) {
174
- sessionLog.error(`Failed to release lock: ${releaseError}`);
175
- }
176
- }
177
- if (hasSlackContext) {
178
- try {
179
- await sendSlackError(queueMessage, error instanceof Error ? error.message : 'Unknown error');
180
- await addReaction(queueMessage, 'x');
181
- }
182
- catch (slackError) {
183
- sessionLog.error(`Failed to send Slack error: ${slackError}`);
184
- }
185
- }
186
- throw error;
187
- }
188
- finally {
189
- clearInterval(heartbeat);
190
- processingMessages.delete(messageId);
191
- signalThreadCompletion(threadId);
192
- }
193
- }
194
- async function pollLoop() {
195
- logger.info('Poll loop started');
196
- while (true) {
197
- try {
198
- if (isAtCapacity()) {
199
- logger.debug(`At capacity (${getActiveProcessCount()}/${config.MAX_CONCURRENT_SESSIONS}), waiting...`);
200
- await sleep(1000);
201
- continue;
202
- }
203
- const availableSlots = config.MAX_CONCURRENT_SESSIONS - getActiveProcessCount();
204
- logger.debug(`Polling for up to ${availableSlots} messages...`);
205
- const messages = await pollMessages(availableSlots);
206
- if (messages.length === 0) {
207
- continue;
208
- }
209
- logger.info(`Received ${messages.length} message(s) from SQS`);
210
- logQueueState('After SQS poll');
211
- const processPromises = messages.map((msg) => processMessage(msg).catch((error) => {
212
- logger.error(`Failed to process message ${msg.messageId}:`, error);
213
- }));
214
- // Don't await - let them run in parallel
215
- Promise.all(processPromises);
216
- }
217
- catch (error) {
218
- logger.error('Error in poll loop:', error);
219
- await sleep(5000);
220
- }
221
- }
222
- }
223
- function sleep(ms) {
224
- return new Promise((resolve) => setTimeout(resolve, ms));
225
- }
226
- // Graceful shutdown
227
- let shuttingDown = false;
228
- async function shutdown(signal) {
229
- if (shuttingDown)
230
- return;
231
- shuttingDown = true;
232
- logger.info(`${signal} received, shutting down gracefully...`);
233
- stopRefresh();
234
- const shutdownTimeout = 30000;
235
- const startTime = Date.now();
236
- while (processingMessages.size > 0) {
237
- if (Date.now() - startTime > shutdownTimeout) {
238
- logger.warn('Shutdown timeout reached, forcing exit');
239
- break;
240
- }
241
- logger.info(`Waiting for ${processingMessages.size} message(s) to complete...`);
242
- await sleep(1000);
243
- }
244
- logger.info('Shutdown complete');
245
- process.exit(0);
246
- }
247
- process.on('SIGINT', () => shutdown('SIGINT'));
248
- process.on('SIGTERM', () => shutdown('SIGTERM'));
249
- async function main() {
250
- // Token-based auth: fetch credentials before starting
251
- if (config.TEAMVIBE_API_URL && config.TEAMVIBE_POLLER_TOKEN) {
252
- await initAuth(config.TEAMVIBE_API_URL, config.TEAMVIBE_POLLER_TOKEN);
253
- }
254
- else {
255
- logger.info(` Queue: ${config.SQS_QUEUE_URL}`);
256
- logger.info(` Sessions table: ${config.SESSIONS_TABLE}`);
257
- }
258
- await ensureDirectories();
259
- await ensureBaseBrain();
260
- await pollLoop();
261
- }
262
- main().catch((error) => {
263
- logger.error('Fatal error:', error);
264
- process.exit(1);
265
- });
11
+ export {};
@@ -0,0 +1 @@
1
+ export declare function startPoller(): Promise<void>;
package/dist/poller.js ADDED
@@ -0,0 +1,260 @@
1
+ import { config } from './config.js';
2
+ import { logger } from './logger.js';
3
+ import { pollMessages, deleteMessage, extendVisibility, } from './sqs-poller.js';
4
+ import { spawnClaudeCode, isAtCapacity, getActiveProcessCount } from './claude-spawner.js';
5
+ import { sendSlackError, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
6
+ import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
7
+ import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
8
+ import { initAuth, stopRefresh } from './auth-provider.js';
9
+ // Track active message processing
10
+ const processingMessages = new Set();
11
+ // Per-thread completion signals
12
+ const threadCompletionSignals = new Map();
13
+ const waitingCountByThread = new Map();
14
+ function getQueueStats() {
15
+ let totalWaiting = 0;
16
+ waitingCountByThread.forEach((count) => {
17
+ totalWaiting += count;
18
+ });
19
+ return {
20
+ processing: processingMessages.size,
21
+ threadsWithWaiting: waitingCountByThread.size,
22
+ totalWaiting,
23
+ };
24
+ }
25
+ function logQueueState(context) {
26
+ const stats = getQueueStats();
27
+ logger.info(`[Queue] ${context} | Processing: ${stats.processing}, Threads with waiting: ${stats.threadsWithWaiting}, Total waiting: ${stats.totalWaiting}`);
28
+ }
29
+ function waitForThreadCompletion(threadId) {
30
+ return new Promise((resolve) => {
31
+ const signals = threadCompletionSignals.get(threadId) || [];
32
+ signals.push(resolve);
33
+ threadCompletionSignals.set(threadId, signals);
34
+ const currentCount = waitingCountByThread.get(threadId) || 0;
35
+ waitingCountByThread.set(threadId, currentCount + 1);
36
+ logger.info(`[Queue] Thread ${threadId}: ${currentCount + 1} message(s) now waiting`);
37
+ logQueueState('After enqueue');
38
+ });
39
+ }
40
+ function signalThreadCompletion(threadId) {
41
+ const signals = threadCompletionSignals.get(threadId);
42
+ const waitingCount = signals?.length || 0;
43
+ if (signals && signals.length > 0) {
44
+ logger.info(`[Queue] Thread ${threadId} completed, waking ${waitingCount} waiting message(s)`);
45
+ signals.forEach((resolve) => resolve());
46
+ threadCompletionSignals.delete(threadId);
47
+ waitingCountByThread.delete(threadId);
48
+ logQueueState('After signal');
49
+ }
50
+ }
51
+ function startHeartbeat(receiptHandle, sessionLog) {
52
+ return setInterval(async () => {
53
+ try {
54
+ await extendVisibility(receiptHandle, config.VISIBILITY_TIMEOUT_SECONDS);
55
+ sessionLog.info(`Heartbeat: extended visibility by ${config.VISIBILITY_TIMEOUT_SECONDS}s`);
56
+ }
57
+ catch (error) {
58
+ sessionLog.error(`Heartbeat failed: ${error instanceof Error ? error.message : error}`);
59
+ }
60
+ }, config.HEARTBEAT_INTERVAL_MS);
61
+ }
62
+ async function processMessage(received) {
63
+ const { queueMessage, receiptHandle, messageId } = received;
64
+ const logSessionId = messageId.slice(0, 8);
65
+ const threadId = queueMessage.thread_id;
66
+ const sessionLog = logger.createSession(logSessionId);
67
+ // Fetch user info if we only have ID
68
+ if (queueMessage.source !== 'cron' &&
69
+ queueMessage.sender.name === 'Unknown User' &&
70
+ queueMessage.sender.id !== 'unknown') {
71
+ const userInfo = await getUserInfo(queueMessage.teamvibe.botToken, queueMessage.sender.id);
72
+ queueMessage.sender.name = userInfo.realName;
73
+ sessionLog.info(`Resolved user: ${userInfo.realName} (@${userInfo.name})`);
74
+ }
75
+ sessionLog.info(`Processing message from ${queueMessage.sender.name} (${queueMessage.sender.id})`);
76
+ sessionLog.info(`Thread: ${threadId}`);
77
+ sessionLog.info(`Brain: ${queueMessage.teamvibe.brain?.brainId ?? 'none'}`);
78
+ sessionLog.info(`Type: ${queueMessage.type}`);
79
+ sessionLog.info(`Log file: ${sessionLog.getLogFile()}`);
80
+ const hasSlackContext = Boolean(queueMessage.response_context.slack?.channel &&
81
+ queueMessage.response_context.slack?.message_ts);
82
+ // Get brain path for this channel
83
+ let kbPath;
84
+ try {
85
+ kbPath = await getBrainPath(queueMessage.teamvibe.brain);
86
+ }
87
+ catch (error) {
88
+ const errorMessage = error instanceof Error ? error.message : String(error);
89
+ sessionLog.error(`Failed to clone brain: ${errorMessage}`);
90
+ if (hasSlackContext) {
91
+ try {
92
+ await sendSlackError(queueMessage, `Failed to clone brain repository: ${errorMessage}`);
93
+ await addReaction(queueMessage, 'x');
94
+ }
95
+ catch (slackError) {
96
+ sessionLog.error(`Failed to send Slack error: ${slackError}`);
97
+ }
98
+ }
99
+ await deleteMessage(receiptHandle);
100
+ return;
101
+ }
102
+ // Try to acquire session lock
103
+ let lockResult = await acquireSessionLock(threadId, kbPath);
104
+ if (!lockResult.success) {
105
+ sessionLog.info(`Session ${threadId} is processing, waiting for completion...`);
106
+ const waitHeartbeat = startHeartbeat(receiptHandle, sessionLog);
107
+ try {
108
+ await waitForThreadCompletion(threadId);
109
+ sessionLog.info(`Thread ${threadId} completed, retrying lock acquisition`);
110
+ lockResult = await acquireSessionLock(threadId, kbPath);
111
+ if (!lockResult.success) {
112
+ sessionLog.info('Lock still held, re-queuing for next completion');
113
+ clearInterval(waitHeartbeat);
114
+ return processMessage(received);
115
+ }
116
+ }
117
+ finally {
118
+ clearInterval(waitHeartbeat);
119
+ }
120
+ }
121
+ const { session, lockToken } = lockResult;
122
+ const isFirstMessage = session.message_count === 1;
123
+ sessionLog.info(`Acquired lock on session ${threadId}, Claude session: ${session.session_id}, mode: ${isFirstMessage ? 'new' : 'resume'}, message #${session.message_count}`);
124
+ logQueueState(`Lock acquired for ${threadId}`);
125
+ processingMessages.add(messageId);
126
+ const heartbeat = startHeartbeat(receiptHandle, sessionLog);
127
+ // Start typing indicator
128
+ const stopTyping = hasSlackContext
129
+ ? startTypingIndicator(queueMessage)
130
+ : undefined;
131
+ try {
132
+ const result = await spawnClaudeCode(queueMessage, sessionLog, kbPath, session.session_id || undefined, isFirstMessage, session.last_message_ts, () => stopTyping?.());
133
+ stopTyping?.();
134
+ // If Claude generated a new session ID (stale lock recovery), persist it
135
+ if (result.newSessionId && lockToken) {
136
+ await updateSessionId(threadId, lockToken, result.newSessionId);
137
+ }
138
+ if (result.success) {
139
+ sessionLog.info('Claude Code completed successfully');
140
+ // Push any changes in the channel brain repo
141
+ if (queueMessage.teamvibe.brain?.brainId) {
142
+ await pushBrainChanges(kbPath, queueMessage.teamvibe.brain.brainId);
143
+ }
144
+ if (lockToken) {
145
+ const lastMessageTs = queueMessage.response_context.slack?.message_ts;
146
+ await releaseSessionLock(threadId, lockToken, 'idle', lastMessageTs);
147
+ }
148
+ }
149
+ else {
150
+ sessionLog.error(`Claude Code failed: ${result.error}`);
151
+ if (hasSlackContext) {
152
+ await sendSlackError(queueMessage, result.error || `Process exited with code ${result.exitCode}`);
153
+ await addReaction(queueMessage, 'x');
154
+ }
155
+ if (lockToken) {
156
+ await releaseSessionLock(threadId, lockToken, 'idle');
157
+ }
158
+ }
159
+ await deleteMessage(receiptHandle);
160
+ sessionLog.info('Message processed and deleted');
161
+ }
162
+ catch (error) {
163
+ stopTyping?.();
164
+ sessionLog.error(`Error processing message: ${error instanceof Error ? error.message : error}`);
165
+ if (lockToken) {
166
+ try {
167
+ await releaseSessionLock(threadId, lockToken, 'idle');
168
+ }
169
+ catch (releaseError) {
170
+ sessionLog.error(`Failed to release lock: ${releaseError}`);
171
+ }
172
+ }
173
+ if (hasSlackContext) {
174
+ try {
175
+ await sendSlackError(queueMessage, error instanceof Error ? error.message : 'Unknown error');
176
+ await addReaction(queueMessage, 'x');
177
+ }
178
+ catch (slackError) {
179
+ sessionLog.error(`Failed to send Slack error: ${slackError}`);
180
+ }
181
+ }
182
+ throw error;
183
+ }
184
+ finally {
185
+ clearInterval(heartbeat);
186
+ processingMessages.delete(messageId);
187
+ signalThreadCompletion(threadId);
188
+ }
189
+ }
190
+ async function pollLoop() {
191
+ logger.info('Poll loop started');
192
+ while (true) {
193
+ try {
194
+ if (isAtCapacity()) {
195
+ logger.debug(`At capacity (${getActiveProcessCount()}/${config.MAX_CONCURRENT_SESSIONS}), waiting...`);
196
+ await sleep(1000);
197
+ continue;
198
+ }
199
+ const availableSlots = config.MAX_CONCURRENT_SESSIONS - getActiveProcessCount();
200
+ logger.debug(`Polling for up to ${availableSlots} messages...`);
201
+ const messages = await pollMessages(availableSlots);
202
+ if (messages.length === 0) {
203
+ continue;
204
+ }
205
+ logger.info(`Received ${messages.length} message(s) from SQS`);
206
+ logQueueState('After SQS poll');
207
+ const processPromises = messages.map((msg) => processMessage(msg).catch((error) => {
208
+ logger.error(`Failed to process message ${msg.messageId}:`, error);
209
+ }));
210
+ // Don't await - let them run in parallel
211
+ Promise.all(processPromises);
212
+ }
213
+ catch (error) {
214
+ logger.error('Error in poll loop:', error);
215
+ await sleep(5000);
216
+ }
217
+ }
218
+ }
219
+ function sleep(ms) {
220
+ return new Promise((resolve) => setTimeout(resolve, ms));
221
+ }
222
+ // Graceful shutdown
223
+ let shuttingDown = false;
224
+ async function shutdown(signal) {
225
+ if (shuttingDown)
226
+ return;
227
+ shuttingDown = true;
228
+ logger.info(`${signal} received, shutting down gracefully...`);
229
+ stopRefresh();
230
+ const shutdownTimeout = 30000;
231
+ const startTime = Date.now();
232
+ while (processingMessages.size > 0) {
233
+ if (Date.now() - startTime > shutdownTimeout) {
234
+ logger.warn('Shutdown timeout reached, forcing exit');
235
+ break;
236
+ }
237
+ logger.info(`Waiting for ${processingMessages.size} message(s) to complete...`);
238
+ await sleep(1000);
239
+ }
240
+ logger.info('Shutdown complete');
241
+ process.exit(0);
242
+ }
243
+ export async function startPoller() {
244
+ logger.info('TeamVibe Poller starting...');
245
+ logger.info(` Max concurrent: ${config.MAX_CONCURRENT_SESSIONS}`);
246
+ logger.info(` Brains path: ${config.BRAINS_PATH}`);
247
+ process.on('SIGINT', () => shutdown('SIGINT'));
248
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
249
+ // Token-based auth: fetch credentials before starting
250
+ if (config.TEAMVIBE_API_URL && config.TEAMVIBE_POLLER_TOKEN) {
251
+ await initAuth(config.TEAMVIBE_API_URL, config.TEAMVIBE_POLLER_TOKEN);
252
+ }
253
+ else {
254
+ logger.info(` Queue: ${config.SQS_QUEUE_URL}`);
255
+ logger.info(` Sessions table: ${config.SESSIONS_TABLE}`);
256
+ }
257
+ await ensureDirectories();
258
+ await ensureBaseBrain();
259
+ await pollLoop();
260
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {