claude-threads 0.12.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +142 -37
- package/dist/claude/cli.d.ts +8 -0
- package/dist/claude/cli.js +16 -8
- package/dist/config/migration.d.ts +45 -0
- package/dist/config/migration.js +35 -0
- package/dist/config.d.ts +12 -18
- package/dist/config.js +7 -94
- package/dist/git/worktree.d.ts +0 -4
- package/dist/git/worktree.js +1 -1
- package/dist/index.js +39 -15
- package/dist/logo.d.ts +3 -20
- package/dist/logo.js +7 -23
- package/dist/mcp/permission-server.js +61 -112
- package/dist/onboarding.d.ts +1 -1
- package/dist/onboarding.js +271 -69
- package/dist/persistence/session-store.d.ts +8 -2
- package/dist/persistence/session-store.js +41 -16
- package/dist/platform/client.d.ts +140 -0
- package/dist/platform/formatter.d.ts +74 -0
- package/dist/platform/index.d.ts +11 -0
- package/dist/platform/index.js +8 -0
- package/dist/platform/mattermost/client.d.ts +70 -0
- package/dist/{mattermost → platform/mattermost}/client.js +117 -34
- package/dist/platform/mattermost/formatter.d.ts +20 -0
- package/dist/platform/mattermost/formatter.js +46 -0
- package/dist/platform/mattermost/permission-api.d.ts +10 -0
- package/dist/platform/mattermost/permission-api.js +139 -0
- package/dist/platform/mattermost/types.js +1 -0
- package/dist/platform/permission-api-factory.d.ts +11 -0
- package/dist/platform/permission-api-factory.js +21 -0
- package/dist/platform/permission-api.d.ts +67 -0
- package/dist/platform/permission-api.js +8 -0
- package/dist/platform/types.d.ts +70 -0
- package/dist/platform/types.js +7 -0
- package/dist/session/commands.d.ts +52 -0
- package/dist/session/commands.js +323 -0
- package/dist/session/events.d.ts +25 -0
- package/dist/session/events.js +368 -0
- package/dist/session/index.d.ts +7 -0
- package/dist/session/index.js +6 -0
- package/dist/session/lifecycle.d.ts +70 -0
- package/dist/session/lifecycle.js +456 -0
- package/dist/session/manager.d.ts +96 -0
- package/dist/session/manager.js +537 -0
- package/dist/session/reactions.d.ts +25 -0
- package/dist/session/reactions.js +151 -0
- package/dist/session/streaming.d.ts +47 -0
- package/dist/session/streaming.js +152 -0
- package/dist/session/types.d.ts +78 -0
- package/dist/session/types.js +9 -0
- package/dist/session/worktree.d.ts +56 -0
- package/dist/session/worktree.js +339 -0
- package/dist/{mattermost → utils}/emoji.d.ts +3 -3
- package/dist/{mattermost → utils}/emoji.js +3 -3
- package/dist/utils/emoji.test.d.ts +1 -0
- package/dist/utils/tool-formatter.d.ts +10 -13
- package/dist/utils/tool-formatter.js +48 -43
- package/dist/utils/tool-formatter.test.js +67 -52
- package/package.json +2 -3
- package/dist/claude/session.d.ts +0 -256
- package/dist/claude/session.js +0 -1964
- package/dist/mattermost/client.d.ts +0 -56
- /package/dist/{mattermost/emoji.test.d.ts → platform/client.js} +0 -0
- /package/dist/{mattermost/types.js → platform/formatter.js} +0 -0
- /package/dist/{mattermost → platform/mattermost}/types.d.ts +0 -0
- /package/dist/{mattermost → utils}/emoji.test.js +0 -0
package/dist/onboarding.js
CHANGED
|
@@ -1,116 +1,318 @@
|
|
|
1
1
|
import prompts from 'prompts';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { CONFIG_PATH, saveConfig, } from './config/migration.js';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
5
6
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
6
7
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
7
8
|
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
8
|
-
|
|
9
|
+
const onCancel = () => {
|
|
9
10
|
console.log('');
|
|
10
|
-
console.log(
|
|
11
|
+
console.log(dim(' Setup cancelled.'));
|
|
12
|
+
process.exit(0);
|
|
13
|
+
};
|
|
14
|
+
export async function runOnboarding(reconfigure = false) {
|
|
15
|
+
console.log('');
|
|
16
|
+
console.log(bold(' claude-threads setup'));
|
|
11
17
|
console.log(dim(' ─────────────────────────────────'));
|
|
12
18
|
console.log('');
|
|
13
|
-
|
|
19
|
+
// Load existing config if reconfiguring
|
|
20
|
+
let existingConfig = null;
|
|
21
|
+
if (reconfigure && existsSync(CONFIG_PATH)) {
|
|
22
|
+
try {
|
|
23
|
+
const content = readFileSync(CONFIG_PATH, 'utf-8');
|
|
24
|
+
existingConfig = YAML.parse(content);
|
|
25
|
+
console.log(dim(' Reconfiguring existing setup.'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
console.log(dim(' Could not load existing config, starting fresh.'));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.log(' Welcome! Let\'s configure claude-threads.');
|
|
33
|
+
}
|
|
34
|
+
console.log('');
|
|
35
|
+
// Step 1: Global settings
|
|
36
|
+
const globalSettings = await prompts([
|
|
37
|
+
{
|
|
38
|
+
type: 'text',
|
|
39
|
+
name: 'workingDir',
|
|
40
|
+
message: 'Default working directory',
|
|
41
|
+
initial: existingConfig?.workingDir || process.cwd(),
|
|
42
|
+
hint: 'Where Claude Code runs by default',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: 'confirm',
|
|
46
|
+
name: 'chrome',
|
|
47
|
+
message: 'Enable Chrome integration?',
|
|
48
|
+
initial: existingConfig?.chrome || false,
|
|
49
|
+
hint: 'Requires Claude in Chrome extension',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
type: 'select',
|
|
53
|
+
name: 'worktreeMode',
|
|
54
|
+
message: 'Git worktree mode',
|
|
55
|
+
choices: [
|
|
56
|
+
{ title: 'Prompt', value: 'prompt', description: 'Ask when starting sessions' },
|
|
57
|
+
{ title: 'Off', value: 'off', description: 'Never use worktrees' },
|
|
58
|
+
{ title: 'Require', value: 'require', description: 'Always require branch name' },
|
|
59
|
+
],
|
|
60
|
+
initial: existingConfig?.worktreeMode === 'off' ? 1 :
|
|
61
|
+
existingConfig?.worktreeMode === 'require' ? 2 : 0,
|
|
62
|
+
},
|
|
63
|
+
], { onCancel });
|
|
64
|
+
const config = {
|
|
65
|
+
version: 2,
|
|
66
|
+
...globalSettings,
|
|
67
|
+
platforms: [],
|
|
68
|
+
};
|
|
69
|
+
// Step 2: Add platforms (loop)
|
|
14
70
|
console.log('');
|
|
15
|
-
console.log(dim('
|
|
16
|
-
console.log(dim(' • A Mattermost bot account with a token'));
|
|
17
|
-
console.log(dim(' • A channel ID where the bot will listen'));
|
|
71
|
+
console.log(dim(' Now let\'s add your platform connections.'));
|
|
18
72
|
console.log('');
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
73
|
+
let platformNumber = 1;
|
|
74
|
+
let addMore = true;
|
|
75
|
+
while (addMore) {
|
|
76
|
+
const isFirst = platformNumber === 1;
|
|
77
|
+
const existingPlatform = existingConfig?.platforms[platformNumber - 1];
|
|
78
|
+
// Ask what platform type
|
|
79
|
+
const { platformType } = await prompts({
|
|
80
|
+
type: 'select',
|
|
81
|
+
name: 'platformType',
|
|
82
|
+
message: isFirst ? 'First platform' : `Platform #${platformNumber}`,
|
|
83
|
+
choices: [
|
|
84
|
+
{ title: 'Mattermost', value: 'mattermost' },
|
|
85
|
+
{ title: 'Slack', value: 'slack' },
|
|
86
|
+
...(isFirst ? [] : [{ title: '(Done - finish setup)', value: 'done' }]),
|
|
87
|
+
],
|
|
88
|
+
initial: existingPlatform?.type === 'slack' ? 1 : 0,
|
|
89
|
+
}, { onCancel });
|
|
90
|
+
if (platformType === 'done') {
|
|
91
|
+
addMore = false;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
// Get platform ID and name
|
|
95
|
+
const { platformId, displayName } = await prompts([
|
|
96
|
+
{
|
|
97
|
+
type: 'text',
|
|
98
|
+
name: 'platformId',
|
|
99
|
+
message: 'Platform ID',
|
|
100
|
+
initial: existingPlatform?.id ||
|
|
101
|
+
(config.platforms.length === 0 ? 'default' : `${platformType}-${platformNumber}`),
|
|
102
|
+
hint: 'Unique identifier (e.g., mattermost-main, slack-eng)',
|
|
103
|
+
validate: (v) => {
|
|
104
|
+
if (!v.match(/^[a-z0-9-]+$/))
|
|
105
|
+
return 'Use lowercase letters, numbers, hyphens only';
|
|
106
|
+
if (config.platforms.some(p => p.id === v))
|
|
107
|
+
return 'ID already in use';
|
|
108
|
+
return true;
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: 'text',
|
|
113
|
+
name: 'displayName',
|
|
114
|
+
message: 'Display name',
|
|
115
|
+
initial: existingPlatform?.displayName ||
|
|
116
|
+
(platformType === 'mattermost' ? 'Mattermost' : 'Slack'),
|
|
117
|
+
hint: 'Human-readable name (e.g., "Internal Team", "Engineering")',
|
|
118
|
+
},
|
|
119
|
+
], { onCancel });
|
|
120
|
+
// Configure the platform
|
|
121
|
+
if (platformType === 'mattermost') {
|
|
122
|
+
const platform = await setupMattermostPlatform(platformId, displayName, existingPlatform);
|
|
123
|
+
config.platforms.push(platform);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const platform = await setupSlackPlatform(platformId, displayName, existingPlatform);
|
|
127
|
+
config.platforms.push(platform);
|
|
128
|
+
}
|
|
129
|
+
console.log(green(` ✓ Added ${displayName}`));
|
|
22
130
|
console.log('');
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
131
|
+
// Ask to add more (after first one)
|
|
132
|
+
if (platformNumber === 1) {
|
|
133
|
+
const { addAnother } = await prompts({
|
|
134
|
+
type: 'confirm',
|
|
135
|
+
name: 'addAnother',
|
|
136
|
+
message: 'Add another platform?',
|
|
137
|
+
initial: (existingConfig?.platforms.length || 0) > 1,
|
|
138
|
+
}, { onCancel });
|
|
139
|
+
addMore = addAnother;
|
|
140
|
+
}
|
|
141
|
+
platformNumber++;
|
|
142
|
+
}
|
|
143
|
+
// Validate at least one platform
|
|
144
|
+
if (config.platforms.length === 0) {
|
|
145
|
+
console.log('');
|
|
146
|
+
console.log(dim(' ⚠️ No platforms configured. Setup cancelled.'));
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
// Save config
|
|
150
|
+
saveConfig(config);
|
|
151
|
+
console.log('');
|
|
152
|
+
console.log(green(' ✓ Configuration saved!'));
|
|
153
|
+
console.log(dim(` ${CONFIG_PATH}`));
|
|
154
|
+
console.log('');
|
|
155
|
+
console.log(dim(` Configured ${config.platforms.length} platform(s):`));
|
|
156
|
+
for (const platform of config.platforms) {
|
|
157
|
+
console.log(dim(` • ${platform.displayName} (${platform.type})`));
|
|
158
|
+
}
|
|
159
|
+
console.log('');
|
|
160
|
+
console.log(dim(' Starting claude-threads...'));
|
|
161
|
+
console.log('');
|
|
162
|
+
}
|
|
163
|
+
async function setupMattermostPlatform(id, displayName, existing) {
|
|
164
|
+
console.log('');
|
|
165
|
+
console.log(dim(' Mattermost setup:'));
|
|
166
|
+
console.log('');
|
|
167
|
+
const existingMattermost = existing?.type === 'mattermost' ? existing : undefined;
|
|
26
168
|
const response = await prompts([
|
|
27
169
|
{
|
|
28
170
|
type: 'text',
|
|
29
171
|
name: 'url',
|
|
30
|
-
message: '
|
|
31
|
-
initial: 'https://
|
|
32
|
-
validate: (v) => v.startsWith('http') ? true : '
|
|
172
|
+
message: 'Server URL',
|
|
173
|
+
initial: existingMattermost?.url || 'https://chat.example.com',
|
|
174
|
+
validate: (v) => v.startsWith('http') ? true : 'Must start with http(s)://',
|
|
33
175
|
},
|
|
34
176
|
{
|
|
35
177
|
type: 'password',
|
|
36
178
|
name: 'token',
|
|
37
179
|
message: 'Bot token',
|
|
38
|
-
|
|
39
|
-
|
|
180
|
+
initial: existingMattermost?.token,
|
|
181
|
+
hint: existingMattermost?.token ? 'Enter to keep existing, or type new token' : 'Create at: Integrations > Bot Accounts',
|
|
182
|
+
validate: (v) => {
|
|
183
|
+
// Allow empty if we have existing token
|
|
184
|
+
if (!v && existingMattermost?.token)
|
|
185
|
+
return true;
|
|
186
|
+
return v.length > 0 ? true : 'Token is required';
|
|
187
|
+
},
|
|
40
188
|
},
|
|
41
189
|
{
|
|
42
190
|
type: 'text',
|
|
43
191
|
name: 'channelId',
|
|
44
192
|
message: 'Channel ID',
|
|
45
|
-
|
|
193
|
+
initial: existingMattermost?.channelId || '',
|
|
194
|
+
hint: 'Click channel > View Info > copy ID from URL',
|
|
46
195
|
validate: (v) => v.length > 0 ? true : 'Channel ID is required',
|
|
47
196
|
},
|
|
48
197
|
{
|
|
49
198
|
type: 'text',
|
|
50
199
|
name: 'botName',
|
|
51
200
|
message: 'Bot mention name',
|
|
52
|
-
initial: 'claude-code',
|
|
201
|
+
initial: existingMattermost?.botName || 'claude-code',
|
|
53
202
|
hint: 'Users will @mention this name',
|
|
54
203
|
},
|
|
55
204
|
{
|
|
56
205
|
type: 'text',
|
|
57
206
|
name: 'allowedUsers',
|
|
58
|
-
message: 'Allowed usernames',
|
|
59
|
-
initial: '',
|
|
60
|
-
hint: 'Comma-separated, or empty
|
|
207
|
+
message: 'Allowed usernames (optional)',
|
|
208
|
+
initial: existingMattermost?.allowedUsers?.join(',') || '',
|
|
209
|
+
hint: 'Comma-separated, or empty to allow everyone',
|
|
61
210
|
},
|
|
62
211
|
{
|
|
63
212
|
type: 'confirm',
|
|
64
213
|
name: 'skipPermissions',
|
|
65
|
-
message: '
|
|
66
|
-
initial:
|
|
67
|
-
hint: 'If no, you\'ll approve
|
|
214
|
+
message: 'Auto-approve all actions?',
|
|
215
|
+
initial: existingMattermost?.skipPermissions || false,
|
|
216
|
+
hint: 'If no, you\'ll approve via emoji reactions',
|
|
68
217
|
},
|
|
69
218
|
], { onCancel });
|
|
70
|
-
//
|
|
71
|
-
|
|
219
|
+
// Use existing token if user left it empty
|
|
220
|
+
const finalToken = response.token || existingMattermost?.token;
|
|
221
|
+
if (!finalToken) {
|
|
72
222
|
console.log('');
|
|
73
|
-
console.log(dim('
|
|
74
|
-
process.exit(1);
|
|
75
|
-
}
|
|
76
|
-
// Build .env content
|
|
77
|
-
const envContent = `# claude-threads configuration
|
|
78
|
-
# Generated by claude-threads onboarding
|
|
79
|
-
|
|
80
|
-
# Mattermost server URL
|
|
81
|
-
MATTERMOST_URL=${response.url}
|
|
82
|
-
|
|
83
|
-
# Bot token (from Integrations > Bot Accounts)
|
|
84
|
-
MATTERMOST_TOKEN=${response.token}
|
|
85
|
-
|
|
86
|
-
# Channel ID where the bot listens
|
|
87
|
-
MATTERMOST_CHANNEL_ID=${response.channelId}
|
|
88
|
-
|
|
89
|
-
# Bot mention name (users @mention this)
|
|
90
|
-
MATTERMOST_BOT_NAME=${response.botName || 'claude-code'}
|
|
91
|
-
|
|
92
|
-
# Allowed usernames (comma-separated, empty = all users)
|
|
93
|
-
ALLOWED_USERS=${response.allowedUsers || ''}
|
|
94
|
-
|
|
95
|
-
# Skip permission prompts (true = auto-approve, false = require emoji approval)
|
|
96
|
-
SKIP_PERMISSIONS=${response.skipPermissions ? 'true' : 'false'}
|
|
97
|
-
`;
|
|
98
|
-
// Save to ~/.config/claude-threads/.env
|
|
99
|
-
const configDir = resolve(homedir(), '.config', 'claude-threads');
|
|
100
|
-
const envPath = resolve(configDir, '.env');
|
|
101
|
-
try {
|
|
102
|
-
mkdirSync(configDir, { recursive: true });
|
|
103
|
-
writeFileSync(envPath, envContent, { mode: 0o600 }); // Secure permissions
|
|
104
|
-
}
|
|
105
|
-
catch (err) {
|
|
106
|
-
console.error('');
|
|
107
|
-
console.error(` Failed to save config: ${err}`);
|
|
223
|
+
console.log(dim(' ⚠️ Token is required. Setup cancelled.'));
|
|
108
224
|
process.exit(1);
|
|
109
225
|
}
|
|
226
|
+
return {
|
|
227
|
+
id,
|
|
228
|
+
type: 'mattermost',
|
|
229
|
+
displayName,
|
|
230
|
+
url: response.url,
|
|
231
|
+
token: finalToken,
|
|
232
|
+
channelId: response.channelId,
|
|
233
|
+
botName: response.botName,
|
|
234
|
+
allowedUsers: response.allowedUsers?.split(',').map((u) => u.trim()).filter((u) => u) || [],
|
|
235
|
+
skipPermissions: response.skipPermissions,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
async function setupSlackPlatform(id, displayName, existing) {
|
|
110
239
|
console.log('');
|
|
111
|
-
console.log(
|
|
112
|
-
console.log(dim(
|
|
113
|
-
console.log('');
|
|
114
|
-
console.log(dim(' Starting claude-threads...'));
|
|
240
|
+
console.log(dim(' Slack setup (requires Socket Mode):'));
|
|
241
|
+
console.log(dim(' Create app at: api.slack.com/apps'));
|
|
115
242
|
console.log('');
|
|
243
|
+
const existingSlack = existing?.type === 'slack' ? existing : undefined;
|
|
244
|
+
const response = await prompts([
|
|
245
|
+
{
|
|
246
|
+
type: 'password',
|
|
247
|
+
name: 'botToken',
|
|
248
|
+
message: 'Bot User OAuth Token',
|
|
249
|
+
initial: existingSlack?.botToken,
|
|
250
|
+
hint: existingSlack?.botToken ? 'Enter to keep existing' : 'Starts with xoxb-',
|
|
251
|
+
validate: (v) => {
|
|
252
|
+
if (!v && existingSlack?.botToken)
|
|
253
|
+
return true;
|
|
254
|
+
return v.startsWith('xoxb-') ? true : 'Must start with xoxb-';
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
type: 'password',
|
|
259
|
+
name: 'appToken',
|
|
260
|
+
message: 'App-Level Token',
|
|
261
|
+
initial: existingSlack?.appToken,
|
|
262
|
+
hint: existingSlack?.appToken ? 'Enter to keep existing' : 'Starts with xapp- (enable Socket Mode first)',
|
|
263
|
+
validate: (v) => {
|
|
264
|
+
if (!v && existingSlack?.appToken)
|
|
265
|
+
return true;
|
|
266
|
+
return v.startsWith('xapp-') ? true : 'Must start with xapp-';
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
type: 'text',
|
|
271
|
+
name: 'channelId',
|
|
272
|
+
message: 'Channel ID',
|
|
273
|
+
initial: existingSlack?.channelId || '',
|
|
274
|
+
hint: 'Right-click channel > View details > copy ID',
|
|
275
|
+
validate: (v) => v.length > 0 ? true : 'Channel ID is required',
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
type: 'text',
|
|
279
|
+
name: 'botName',
|
|
280
|
+
message: 'Bot mention name',
|
|
281
|
+
initial: existingSlack?.botName || 'claude',
|
|
282
|
+
hint: 'Users will @mention this name',
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
type: 'text',
|
|
286
|
+
name: 'allowedUsers',
|
|
287
|
+
message: 'Allowed usernames (optional)',
|
|
288
|
+
initial: existingSlack?.allowedUsers?.join(',') || '',
|
|
289
|
+
hint: 'Comma-separated, or empty for everyone',
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
type: 'confirm',
|
|
293
|
+
name: 'skipPermissions',
|
|
294
|
+
message: 'Auto-approve all actions?',
|
|
295
|
+
initial: existingSlack?.skipPermissions || false,
|
|
296
|
+
hint: 'If no, you\'ll approve via emoji reactions',
|
|
297
|
+
},
|
|
298
|
+
], { onCancel });
|
|
299
|
+
// Use existing tokens if user left them empty
|
|
300
|
+
const finalBotToken = response.botToken || existingSlack?.botToken;
|
|
301
|
+
const finalAppToken = response.appToken || existingSlack?.appToken;
|
|
302
|
+
if (!finalBotToken || !finalAppToken) {
|
|
303
|
+
console.log('');
|
|
304
|
+
console.log(dim(' ⚠️ Both tokens are required. Setup cancelled.'));
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
id,
|
|
309
|
+
type: 'slack',
|
|
310
|
+
displayName,
|
|
311
|
+
botToken: finalBotToken,
|
|
312
|
+
appToken: finalAppToken,
|
|
313
|
+
channelId: response.channelId,
|
|
314
|
+
botName: response.botName,
|
|
315
|
+
allowedUsers: response.allowedUsers?.split(',').map((u) => u.trim()).filter((u) => u) || [],
|
|
316
|
+
skipPermissions: response.skipPermissions,
|
|
317
|
+
};
|
|
116
318
|
}
|
|
@@ -10,6 +10,7 @@ export interface WorktreeInfo {
|
|
|
10
10
|
* Persisted session state for resuming after bot restart
|
|
11
11
|
*/
|
|
12
12
|
export interface PersistedSession {
|
|
13
|
+
platformId: string;
|
|
13
14
|
threadId: string;
|
|
14
15
|
claudeSessionId: string;
|
|
15
16
|
startedBy: string;
|
|
@@ -36,18 +37,23 @@ export declare class SessionStore {
|
|
|
36
37
|
constructor();
|
|
37
38
|
/**
|
|
38
39
|
* Load all persisted sessions
|
|
40
|
+
* Returns Map with composite sessionId ("platformId:threadId") as key
|
|
39
41
|
*/
|
|
40
42
|
load(): Map<string, PersistedSession>;
|
|
41
43
|
/**
|
|
42
44
|
* Save a session (creates or updates)
|
|
45
|
+
* @param sessionId - Composite key "platformId:threadId"
|
|
46
|
+
* @param session - Session data to persist
|
|
43
47
|
*/
|
|
44
|
-
save(
|
|
48
|
+
save(sessionId: string, session: PersistedSession): void;
|
|
45
49
|
/**
|
|
46
50
|
* Remove a session
|
|
51
|
+
* @param sessionId - Composite key "platformId:threadId"
|
|
47
52
|
*/
|
|
48
|
-
remove(
|
|
53
|
+
remove(sessionId: string): void;
|
|
49
54
|
/**
|
|
50
55
|
* Remove sessions older than maxAgeMs
|
|
56
|
+
* @returns Array of sessionIds that were removed
|
|
51
57
|
*/
|
|
52
58
|
cleanStale(maxAgeMs: number): string[];
|
|
53
59
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
const STORE_VERSION =
|
|
4
|
+
const STORE_VERSION = 2; // v2: Added platformId for multi-platform support
|
|
5
5
|
const CONFIG_DIR = join(homedir(), '.config', 'claude-threads');
|
|
6
6
|
const SESSIONS_FILE = join(CONFIG_DIR, 'sessions.json');
|
|
7
7
|
/**
|
|
@@ -18,6 +18,7 @@ export class SessionStore {
|
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* Load all persisted sessions
|
|
21
|
+
* Returns Map with composite sessionId ("platformId:threadId") as key
|
|
21
22
|
*/
|
|
22
23
|
load() {
|
|
23
24
|
const sessions = new Map();
|
|
@@ -28,13 +29,32 @@ export class SessionStore {
|
|
|
28
29
|
}
|
|
29
30
|
try {
|
|
30
31
|
const data = JSON.parse(readFileSync(SESSIONS_FILE, 'utf-8'));
|
|
31
|
-
//
|
|
32
|
-
if (data.version
|
|
33
|
-
console.
|
|
32
|
+
// Migration: v1 → v2 (add platformId and convert keys to composite format)
|
|
33
|
+
if (data.version === 1) {
|
|
34
|
+
console.log(' [persist] Migrating sessions from v1 to v2 (adding platformId)');
|
|
35
|
+
const newSessions = {};
|
|
36
|
+
for (const [_oldKey, session] of Object.entries(data.sessions)) {
|
|
37
|
+
const v1Session = session;
|
|
38
|
+
if (!v1Session.platformId) {
|
|
39
|
+
v1Session.platformId = 'default';
|
|
40
|
+
}
|
|
41
|
+
// Convert key from threadId to platformId:threadId
|
|
42
|
+
const newKey = `${v1Session.platformId}:${v1Session.threadId}`;
|
|
43
|
+
newSessions[newKey] = v1Session;
|
|
44
|
+
}
|
|
45
|
+
data.sessions = newSessions;
|
|
46
|
+
data.version = 2;
|
|
47
|
+
// Save migrated data
|
|
48
|
+
this.writeAtomic(data);
|
|
49
|
+
}
|
|
50
|
+
else if (data.version !== STORE_VERSION) {
|
|
51
|
+
console.warn(` [persist] Sessions file version ${data.version} not supported, starting fresh`);
|
|
34
52
|
return sessions;
|
|
35
53
|
}
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
// Load sessions with composite sessionId as key
|
|
55
|
+
for (const session of Object.values(data.sessions)) {
|
|
56
|
+
const sessionId = `${session.platformId}:${session.threadId}`;
|
|
57
|
+
sessions.set(sessionId, session);
|
|
38
58
|
}
|
|
39
59
|
if (this.debug) {
|
|
40
60
|
console.log(` [persist] Loaded ${sessions.size} session(s)`);
|
|
@@ -47,42 +67,47 @@ export class SessionStore {
|
|
|
47
67
|
}
|
|
48
68
|
/**
|
|
49
69
|
* Save a session (creates or updates)
|
|
70
|
+
* @param sessionId - Composite key "platformId:threadId"
|
|
71
|
+
* @param session - Session data to persist
|
|
50
72
|
*/
|
|
51
|
-
save(
|
|
73
|
+
save(sessionId, session) {
|
|
52
74
|
const data = this.loadRaw();
|
|
53
|
-
|
|
75
|
+
// Use sessionId as key (already composite)
|
|
76
|
+
data.sessions[sessionId] = session;
|
|
54
77
|
this.writeAtomic(data);
|
|
55
78
|
if (this.debug) {
|
|
56
|
-
const shortId =
|
|
79
|
+
const shortId = sessionId.substring(0, 20);
|
|
57
80
|
console.log(` [persist] Saved session ${shortId}...`);
|
|
58
81
|
}
|
|
59
82
|
}
|
|
60
83
|
/**
|
|
61
84
|
* Remove a session
|
|
85
|
+
* @param sessionId - Composite key "platformId:threadId"
|
|
62
86
|
*/
|
|
63
|
-
remove(
|
|
87
|
+
remove(sessionId) {
|
|
64
88
|
const data = this.loadRaw();
|
|
65
|
-
if (data.sessions[
|
|
66
|
-
delete data.sessions[
|
|
89
|
+
if (data.sessions[sessionId]) {
|
|
90
|
+
delete data.sessions[sessionId];
|
|
67
91
|
this.writeAtomic(data);
|
|
68
92
|
if (this.debug) {
|
|
69
|
-
const shortId =
|
|
93
|
+
const shortId = sessionId.substring(0, 20);
|
|
70
94
|
console.log(` [persist] Removed session ${shortId}...`);
|
|
71
95
|
}
|
|
72
96
|
}
|
|
73
97
|
}
|
|
74
98
|
/**
|
|
75
99
|
* Remove sessions older than maxAgeMs
|
|
100
|
+
* @returns Array of sessionIds that were removed
|
|
76
101
|
*/
|
|
77
102
|
cleanStale(maxAgeMs) {
|
|
78
103
|
const data = this.loadRaw();
|
|
79
104
|
const now = Date.now();
|
|
80
105
|
const staleIds = [];
|
|
81
|
-
for (const [
|
|
106
|
+
for (const [sessionId, session] of Object.entries(data.sessions)) {
|
|
82
107
|
const lastActivity = new Date(session.lastActivityAt).getTime();
|
|
83
108
|
if (now - lastActivity > maxAgeMs) {
|
|
84
|
-
staleIds.push(
|
|
85
|
-
delete data.sessions[
|
|
109
|
+
staleIds.push(sessionId);
|
|
110
|
+
delete data.sessions[sessionId];
|
|
86
111
|
}
|
|
87
112
|
}
|
|
88
113
|
if (staleIds.length > 0) {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import type { PlatformUser, PlatformPost, PlatformReaction, PlatformFile } from './types.js';
|
|
3
|
+
import type { PlatformFormatter } from './formatter.js';
|
|
4
|
+
/**
|
|
5
|
+
* Events emitted by PlatformClient
|
|
6
|
+
*/
|
|
7
|
+
export interface PlatformClientEvents {
|
|
8
|
+
connected: () => void;
|
|
9
|
+
disconnected: () => void;
|
|
10
|
+
error: (error: Error) => void;
|
|
11
|
+
message: (post: PlatformPost, user: PlatformUser | null) => void;
|
|
12
|
+
reaction: (reaction: PlatformReaction, user: PlatformUser | null) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Platform-agnostic client interface
|
|
16
|
+
*
|
|
17
|
+
* All platform implementations (Mattermost, Slack) must implement this interface.
|
|
18
|
+
* This allows SessionManager and other code to work with any platform without
|
|
19
|
+
* knowing the specific implementation details.
|
|
20
|
+
*/
|
|
21
|
+
export interface PlatformClient extends EventEmitter {
|
|
22
|
+
/**
|
|
23
|
+
* Unique identifier for this platform instance
|
|
24
|
+
* e.g., 'mattermost-internal', 'slack-eng'
|
|
25
|
+
*/
|
|
26
|
+
readonly platformId: string;
|
|
27
|
+
/**
|
|
28
|
+
* Platform type
|
|
29
|
+
* e.g., 'mattermost', 'slack'
|
|
30
|
+
*/
|
|
31
|
+
readonly platformType: string;
|
|
32
|
+
/**
|
|
33
|
+
* Human-readable display name
|
|
34
|
+
* e.g., 'Internal Team', 'Engineering Slack'
|
|
35
|
+
*/
|
|
36
|
+
readonly displayName: string;
|
|
37
|
+
/**
|
|
38
|
+
* Connect to the platform (WebSocket, Socket Mode, etc.)
|
|
39
|
+
*/
|
|
40
|
+
connect(): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Disconnect from the platform
|
|
43
|
+
*/
|
|
44
|
+
disconnect(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Get the bot's own user info
|
|
47
|
+
*/
|
|
48
|
+
getBotUser(): Promise<PlatformUser>;
|
|
49
|
+
/**
|
|
50
|
+
* Get a user by their ID
|
|
51
|
+
*/
|
|
52
|
+
getUser(userId: string): Promise<PlatformUser | null>;
|
|
53
|
+
/**
|
|
54
|
+
* Check if a username is in the allowed users list
|
|
55
|
+
*/
|
|
56
|
+
isUserAllowed(username: string): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Get the bot's mention name (e.g., 'claude-code')
|
|
59
|
+
*/
|
|
60
|
+
getBotName(): string;
|
|
61
|
+
/**
|
|
62
|
+
* Get platform config for MCP permission server
|
|
63
|
+
*/
|
|
64
|
+
getMcpConfig(): {
|
|
65
|
+
type: string;
|
|
66
|
+
url: string;
|
|
67
|
+
token: string;
|
|
68
|
+
channelId: string;
|
|
69
|
+
allowedUsers: string[];
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Get the platform-specific markdown formatter
|
|
73
|
+
* Use this to format bold, code, etc. in a platform-appropriate way.
|
|
74
|
+
*/
|
|
75
|
+
getFormatter(): PlatformFormatter;
|
|
76
|
+
/**
|
|
77
|
+
* Create a new post/message
|
|
78
|
+
* @param message - Message text
|
|
79
|
+
* @param threadId - Optional thread parent ID
|
|
80
|
+
* @returns The created post
|
|
81
|
+
*/
|
|
82
|
+
createPost(message: string, threadId?: string): Promise<PlatformPost>;
|
|
83
|
+
/**
|
|
84
|
+
* Update an existing post/message
|
|
85
|
+
* @param postId - Post ID to update
|
|
86
|
+
* @param message - New message text
|
|
87
|
+
* @returns The updated post
|
|
88
|
+
*/
|
|
89
|
+
updatePost(postId: string, message: string): Promise<PlatformPost>;
|
|
90
|
+
/**
|
|
91
|
+
* Create a post with reaction options (for interactive prompts)
|
|
92
|
+
* @param message - Message text
|
|
93
|
+
* @param reactions - Array of emoji names to add as options
|
|
94
|
+
* @param threadId - Optional thread parent ID
|
|
95
|
+
* @returns The created post
|
|
96
|
+
*/
|
|
97
|
+
createInteractivePost(message: string, reactions: string[], threadId?: string): Promise<PlatformPost>;
|
|
98
|
+
/**
|
|
99
|
+
* Get a post by ID
|
|
100
|
+
* @param postId - Post ID
|
|
101
|
+
* @returns The post, or null if not found/deleted
|
|
102
|
+
*/
|
|
103
|
+
getPost(postId: string): Promise<PlatformPost | null>;
|
|
104
|
+
/**
|
|
105
|
+
* Add a reaction to a post
|
|
106
|
+
* @param postId - Post ID
|
|
107
|
+
* @param emojiName - Emoji name (e.g., '+1', 'white_check_mark')
|
|
108
|
+
*/
|
|
109
|
+
addReaction(postId: string, emojiName: string): Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* Check if a message mentions the bot
|
|
112
|
+
* @param message - Message text
|
|
113
|
+
*/
|
|
114
|
+
isBotMentioned(message: string): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Extract the prompt from a message (remove bot mention)
|
|
117
|
+
* @param message - Message text
|
|
118
|
+
* @returns The message with bot mention removed
|
|
119
|
+
*/
|
|
120
|
+
extractPrompt(message: string): string;
|
|
121
|
+
/**
|
|
122
|
+
* Send typing indicator to show bot is "thinking"
|
|
123
|
+
* @param threadId - Optional thread ID
|
|
124
|
+
*/
|
|
125
|
+
sendTyping(threadId?: string): void;
|
|
126
|
+
/**
|
|
127
|
+
* Download a file attachment
|
|
128
|
+
* @param fileId - File ID
|
|
129
|
+
* @returns File contents as Buffer
|
|
130
|
+
*/
|
|
131
|
+
downloadFile?(fileId: string): Promise<Buffer>;
|
|
132
|
+
/**
|
|
133
|
+
* Get file metadata
|
|
134
|
+
* @param fileId - File ID
|
|
135
|
+
* @returns File metadata
|
|
136
|
+
*/
|
|
137
|
+
getFileInfo?(fileId: string): Promise<PlatformFile>;
|
|
138
|
+
on<K extends keyof PlatformClientEvents>(event: K, listener: PlatformClientEvents[K]): this;
|
|
139
|
+
emit<K extends keyof PlatformClientEvents>(event: K, ...args: Parameters<PlatformClientEvents[K]>): boolean;
|
|
140
|
+
}
|