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.
Files changed (67) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +142 -37
  3. package/dist/claude/cli.d.ts +8 -0
  4. package/dist/claude/cli.js +16 -8
  5. package/dist/config/migration.d.ts +45 -0
  6. package/dist/config/migration.js +35 -0
  7. package/dist/config.d.ts +12 -18
  8. package/dist/config.js +7 -94
  9. package/dist/git/worktree.d.ts +0 -4
  10. package/dist/git/worktree.js +1 -1
  11. package/dist/index.js +39 -15
  12. package/dist/logo.d.ts +3 -20
  13. package/dist/logo.js +7 -23
  14. package/dist/mcp/permission-server.js +61 -112
  15. package/dist/onboarding.d.ts +1 -1
  16. package/dist/onboarding.js +271 -69
  17. package/dist/persistence/session-store.d.ts +8 -2
  18. package/dist/persistence/session-store.js +41 -16
  19. package/dist/platform/client.d.ts +140 -0
  20. package/dist/platform/formatter.d.ts +74 -0
  21. package/dist/platform/index.d.ts +11 -0
  22. package/dist/platform/index.js +8 -0
  23. package/dist/platform/mattermost/client.d.ts +70 -0
  24. package/dist/{mattermost → platform/mattermost}/client.js +117 -34
  25. package/dist/platform/mattermost/formatter.d.ts +20 -0
  26. package/dist/platform/mattermost/formatter.js +46 -0
  27. package/dist/platform/mattermost/permission-api.d.ts +10 -0
  28. package/dist/platform/mattermost/permission-api.js +139 -0
  29. package/dist/platform/mattermost/types.js +1 -0
  30. package/dist/platform/permission-api-factory.d.ts +11 -0
  31. package/dist/platform/permission-api-factory.js +21 -0
  32. package/dist/platform/permission-api.d.ts +67 -0
  33. package/dist/platform/permission-api.js +8 -0
  34. package/dist/platform/types.d.ts +70 -0
  35. package/dist/platform/types.js +7 -0
  36. package/dist/session/commands.d.ts +52 -0
  37. package/dist/session/commands.js +323 -0
  38. package/dist/session/events.d.ts +25 -0
  39. package/dist/session/events.js +368 -0
  40. package/dist/session/index.d.ts +7 -0
  41. package/dist/session/index.js +6 -0
  42. package/dist/session/lifecycle.d.ts +70 -0
  43. package/dist/session/lifecycle.js +456 -0
  44. package/dist/session/manager.d.ts +96 -0
  45. package/dist/session/manager.js +537 -0
  46. package/dist/session/reactions.d.ts +25 -0
  47. package/dist/session/reactions.js +151 -0
  48. package/dist/session/streaming.d.ts +47 -0
  49. package/dist/session/streaming.js +152 -0
  50. package/dist/session/types.d.ts +78 -0
  51. package/dist/session/types.js +9 -0
  52. package/dist/session/worktree.d.ts +56 -0
  53. package/dist/session/worktree.js +339 -0
  54. package/dist/{mattermost → utils}/emoji.d.ts +3 -3
  55. package/dist/{mattermost → utils}/emoji.js +3 -3
  56. package/dist/utils/emoji.test.d.ts +1 -0
  57. package/dist/utils/tool-formatter.d.ts +10 -13
  58. package/dist/utils/tool-formatter.js +48 -43
  59. package/dist/utils/tool-formatter.test.js +67 -52
  60. package/package.json +2 -3
  61. package/dist/claude/session.d.ts +0 -256
  62. package/dist/claude/session.js +0 -1964
  63. package/dist/mattermost/client.d.ts +0 -56
  64. /package/dist/{mattermost/emoji.test.d.ts → platform/client.js} +0 -0
  65. /package/dist/{mattermost/types.js → platform/formatter.js} +0 -0
  66. /package/dist/{mattermost → platform/mattermost}/types.d.ts +0 -0
  67. /package/dist/{mattermost → utils}/emoji.test.js +0 -0
@@ -1,116 +1,318 @@
1
1
  import prompts from 'prompts';
2
- import { writeFileSync, mkdirSync } from 'fs';
3
- import { homedir } from 'os';
4
- import { resolve } from 'path';
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
- export async function runOnboarding() {
9
+ const onCancel = () => {
9
10
  console.log('');
10
- console.log(bold(' Welcome to claude-threads!'));
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
- console.log(' No configuration found. Let\'s set things up.');
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(' You\'ll need:'));
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
- // Handle Ctrl+C gracefully
20
- prompts.override({});
21
- const onCancel = () => {
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
- console.log(dim(' Setup cancelled.'));
24
- process.exit(0);
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: 'Mattermost URL',
31
- initial: 'https://your-mattermost-server.com',
32
- validate: (v) => v.startsWith('http') ? true : 'URL must start with http:// or https://',
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
- hint: 'Create at: Integrations > Bot Accounts > Add Bot Account',
39
- validate: (v) => v.length > 0 ? true : 'Token is required',
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
- hint: 'Click channel name > View Info > copy ID from URL',
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 for all users',
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: 'Skip permission prompts?',
66
- initial: true,
67
- hint: 'If no, you\'ll approve each action via emoji reactions',
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
- // Check if user cancelled
71
- if (!response.url || !response.token || !response.channelId) {
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(' Setup incomplete. Run claude-threads again to retry.'));
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(green(' Configuration saved!'));
112
- console.log(dim(` ${envPath}`));
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(threadId: string, session: PersistedSession): void;
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(threadId: string): void;
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 = 1;
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
- // Version check for future migrations
32
- if (data.version !== STORE_VERSION) {
33
- console.warn(` [persist] Sessions file version mismatch (${data.version} vs ${STORE_VERSION}), starting fresh`);
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
- for (const [threadId, session] of Object.entries(data.sessions)) {
37
- sessions.set(threadId, session);
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(threadId, session) {
73
+ save(sessionId, session) {
52
74
  const data = this.loadRaw();
53
- data.sessions[threadId] = session;
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 = threadId.substring(0, 8);
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(threadId) {
87
+ remove(sessionId) {
64
88
  const data = this.loadRaw();
65
- if (data.sessions[threadId]) {
66
- delete data.sessions[threadId];
89
+ if (data.sessions[sessionId]) {
90
+ delete data.sessions[sessionId];
67
91
  this.writeAtomic(data);
68
92
  if (this.debug) {
69
- const shortId = threadId.substring(0, 8);
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 [threadId, session] of Object.entries(data.sessions)) {
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(threadId);
85
- delete data.sessions[threadId];
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
+ }