@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 +84 -0
- package/dist/claude-spawner.js +11 -10
- package/dist/cli/commands.d.ts +1 -0
- package/dist/cli/commands.js +59 -0
- package/dist/cli/install.d.ts +1 -0
- package/dist/cli/install.js +196 -0
- package/dist/cli/logs.d.ts +1 -0
- package/dist/cli/logs.js +23 -0
- package/dist/cli/plist.d.ts +5 -0
- package/dist/cli/plist.js +60 -0
- package/dist/cli/prompt.d.ts +2 -0
- package/dist/cli/prompt.js +22 -0
- package/dist/cli/service.d.ts +4 -0
- package/dist/cli/service.js +86 -0
- package/dist/cli/uninstall.d.ts +1 -0
- package/dist/cli/uninstall.js +37 -0
- package/dist/cli/update.d.ts +1 -0
- package/dist/cli/update.js +34 -0
- package/dist/index.js +8 -262
- package/dist/poller.d.ts +1 -0
- package/dist/poller.js +260 -0
- package/package.json +1 -1
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.
|
package/dist/claude-spawner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/dist/cli/logs.js
ADDED
|
@@ -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, '&')
|
|
56
|
+
.replace(/</g, '<')
|
|
57
|
+
.replace(/>/g, '>')
|
|
58
|
+
.replace(/"/g, '"')
|
|
59
|
+
.replace(/'/g, ''');
|
|
60
|
+
}
|
|
@@ -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,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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
7
|
+
else {
|
|
8
|
+
const { startPoller } = await import('./poller.js');
|
|
9
|
+
await startPoller();
|
|
32
10
|
}
|
|
33
|
-
|
|
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 {};
|
package/dist/poller.d.ts
ADDED
|
@@ -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
|
+
}
|