claude-threads 0.13.0 → 0.14.1

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 +32 -0
  2. package/README.md +78 -28
  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 +31 -13
  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.js +262 -137
  16. package/dist/persistence/session-store.d.ts +8 -2
  17. package/dist/persistence/session-store.js +41 -16
  18. package/dist/platform/client.d.ts +140 -0
  19. package/dist/platform/formatter.d.ts +74 -0
  20. package/dist/platform/index.d.ts +11 -0
  21. package/dist/platform/index.js +8 -0
  22. package/dist/platform/mattermost/client.d.ts +70 -0
  23. package/dist/{mattermost → platform/mattermost}/client.js +117 -34
  24. package/dist/platform/mattermost/formatter.d.ts +20 -0
  25. package/dist/platform/mattermost/formatter.js +46 -0
  26. package/dist/platform/mattermost/permission-api.d.ts +10 -0
  27. package/dist/platform/mattermost/permission-api.js +139 -0
  28. package/dist/platform/mattermost/types.js +1 -0
  29. package/dist/platform/permission-api-factory.d.ts +11 -0
  30. package/dist/platform/permission-api-factory.js +21 -0
  31. package/dist/platform/permission-api.d.ts +67 -0
  32. package/dist/platform/permission-api.js +8 -0
  33. package/dist/platform/types.d.ts +70 -0
  34. package/dist/platform/types.js +7 -0
  35. package/dist/session/commands.d.ts +52 -0
  36. package/dist/session/commands.js +323 -0
  37. package/dist/session/events.d.ts +25 -0
  38. package/dist/session/events.js +368 -0
  39. package/dist/session/index.d.ts +7 -0
  40. package/dist/session/index.js +6 -0
  41. package/dist/session/lifecycle.d.ts +70 -0
  42. package/dist/session/lifecycle.js +456 -0
  43. package/dist/session/manager.d.ts +96 -0
  44. package/dist/session/manager.js +537 -0
  45. package/dist/session/reactions.d.ts +25 -0
  46. package/dist/session/reactions.js +151 -0
  47. package/dist/session/streaming.d.ts +47 -0
  48. package/dist/session/streaming.js +152 -0
  49. package/dist/session/types.d.ts +78 -0
  50. package/dist/session/types.js +9 -0
  51. package/dist/session/worktree.d.ts +56 -0
  52. package/dist/session/worktree.js +339 -0
  53. package/dist/update-notifier.js +10 -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 +4 -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,85 +1,187 @@
1
1
  import prompts from 'prompts';
2
- import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs';
3
- import { homedir } from 'os';
4
- import { resolve, dirname } from 'path';
5
- import { parse } from 'dotenv';
2
+ import { existsSync } from 'fs';
3
+ import { CONFIG_PATH, saveConfig, } from './config/migration.js';
4
+ import YAML from 'yaml';
5
+ import { readFileSync } from 'fs';
6
6
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
7
7
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
8
8
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
9
- // Paths to search for .env files (in order of priority)
10
- const ENV_PATHS = [
11
- resolve(process.cwd(), '.env'),
12
- resolve(homedir(), '.config', 'claude-threads', '.env'),
13
- resolve(homedir(), '.claude-threads.env'),
14
- ];
15
- function loadExistingConfig() {
16
- for (const envPath of ENV_PATHS) {
17
- if (existsSync(envPath)) {
18
- try {
19
- const content = readFileSync(envPath, 'utf-8');
20
- return { path: envPath, values: parse(content) };
21
- }
22
- catch {
23
- return { path: null, values: {} };
24
- }
9
+ const onCancel = () => {
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'));
17
+ console.log(dim(' ─────────────────────────────────'));
18
+ console.log('');
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.'));
25
29
  }
26
30
  }
27
- return { path: null, values: {} };
28
- }
29
- export async function runOnboarding(reconfigure = false) {
30
- const { path: existingPath, values: existing } = reconfigure ? loadExistingConfig() : { path: null, values: {} };
31
- const hasExisting = Object.keys(existing).length > 0;
31
+ else {
32
+ console.log(' Welcome! Let\'s configure claude-threads.');
33
+ }
32
34
  console.log('');
33
- if (reconfigure && hasExisting) {
34
- console.log(bold(' Reconfiguring claude-threads'));
35
- console.log(dim(' ─────────────────────────────────'));
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)
70
+ console.log('');
71
+ console.log(dim(' Now let\'s add your platform connections.'));
72
+ console.log('');
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}`));
36
130
  console.log('');
37
- console.log(dim(' Press Enter to keep existing values.'));
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++;
38
142
  }
39
- else {
40
- console.log(bold(' Welcome to claude-threads!'));
41
- console.log(dim(' ─────────────────────────────────'));
143
+ // Validate at least one platform
144
+ if (config.platforms.length === 0) {
42
145
  console.log('');
43
- console.log(' No configuration found. Let\'s set things up.');
146
+ console.log(dim(' ⚠️ No platforms configured. Setup cancelled.'));
147
+ process.exit(1);
44
148
  }
149
+ // Save config
150
+ saveConfig(config);
45
151
  console.log('');
46
- console.log(dim(' You\'ll need:'));
47
- console.log(dim(' • A Mattermost bot account with a token'));
48
- console.log(dim(' • A channel ID where the bot will listen'));
152
+ console.log(green(' Configuration saved!'));
153
+ console.log(dim(` ${CONFIG_PATH}`));
49
154
  console.log('');
50
- // Handle Ctrl+C gracefully
51
- prompts.override({});
52
- const onCancel = () => {
53
- console.log('');
54
- console.log(dim(' Setup cancelled.'));
55
- process.exit(0);
56
- };
57
- // Helper to get worktree mode index
58
- const worktreeModeIndex = (mode) => {
59
- if (mode === 'off')
60
- return 1;
61
- if (mode === 'require')
62
- return 2;
63
- return 0; // default to 'prompt'
64
- };
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;
65
168
  const response = await prompts([
66
169
  {
67
170
  type: 'text',
68
171
  name: 'url',
69
- message: 'Mattermost URL',
70
- initial: existing.MATTERMOST_URL || 'https://your-mattermost-server.com',
71
- 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)://',
72
175
  },
73
176
  {
74
177
  type: 'password',
75
178
  name: 'token',
76
179
  message: 'Bot token',
77
- hint: existing.MATTERMOST_TOKEN
78
- ? 'Enter to keep existing, or type new token'
79
- : 'Create at: Integrations > Bot Accounts > Add Bot Account',
180
+ initial: existingMattermost?.token,
181
+ hint: existingMattermost?.token ? 'Enter to keep existing, or type new token' : 'Create at: Integrations > Bot Accounts',
80
182
  validate: (v) => {
81
- // Allow empty if we have an existing token (user wants to keep it)
82
- if (!v && existing.MATTERMOST_TOKEN)
183
+ // Allow empty if we have existing token
184
+ if (!v && existingMattermost?.token)
83
185
  return true;
84
186
  return v.length > 0 ? true : 'Token is required';
85
187
  },
@@ -88,106 +190,129 @@ export async function runOnboarding(reconfigure = false) {
88
190
  type: 'text',
89
191
  name: 'channelId',
90
192
  message: 'Channel ID',
91
- initial: existing.MATTERMOST_CHANNEL_ID || '',
92
- hint: 'Click channel name > View Info > copy ID from URL',
193
+ initial: existingMattermost?.channelId || '',
194
+ hint: 'Click channel > View Info > copy ID from URL',
93
195
  validate: (v) => v.length > 0 ? true : 'Channel ID is required',
94
196
  },
95
197
  {
96
198
  type: 'text',
97
199
  name: 'botName',
98
200
  message: 'Bot mention name',
99
- initial: existing.MATTERMOST_BOT_NAME || 'claude-code',
201
+ initial: existingMattermost?.botName || 'claude-code',
100
202
  hint: 'Users will @mention this name',
101
203
  },
102
204
  {
103
205
  type: 'text',
104
206
  name: 'allowedUsers',
105
- message: 'Allowed usernames',
106
- initial: existing.ALLOWED_USERS || '',
107
- 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',
108
210
  },
109
211
  {
110
212
  type: 'confirm',
111
213
  name: 'skipPermissions',
112
- message: 'Skip permission prompts?',
113
- initial: existing.SKIP_PERMISSIONS !== undefined
114
- ? existing.SKIP_PERMISSIONS === 'true'
115
- : true,
116
- 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',
117
217
  },
218
+ ], { onCancel });
219
+ // Use existing token if user left it empty
220
+ const finalToken = response.token || existingMattermost?.token;
221
+ if (!finalToken) {
222
+ console.log('');
223
+ console.log(dim(' ⚠️ Token is required. Setup cancelled.'));
224
+ process.exit(1);
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) {
239
+ console.log('');
240
+ console.log(dim(' Slack setup (requires Socket Mode):'));
241
+ console.log(dim(' Create app at: api.slack.com/apps'));
242
+ console.log('');
243
+ const existingSlack = existing?.type === 'slack' ? existing : undefined;
244
+ const response = await prompts([
118
245
  {
119
- type: 'confirm',
120
- name: 'chrome',
121
- message: 'Enable Chrome integration?',
122
- initial: existing.CLAUDE_CHROME === 'true',
123
- hint: 'Requires Claude in Chrome extension',
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
+ },
124
256
  },
125
257
  {
126
- type: 'select',
127
- name: 'worktreeMode',
128
- message: 'Git worktree mode',
129
- hint: 'Isolate changes in separate worktrees',
130
- choices: [
131
- { title: 'Prompt', value: 'prompt', description: 'Ask when starting new sessions' },
132
- { title: 'Off', value: 'off', description: 'Never use worktrees' },
133
- { title: 'Require', value: 'require', description: 'Always require a branch name' },
134
- ],
135
- initial: worktreeModeIndex(existing.WORKTREE_MODE),
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',
136
297
  },
137
298
  ], { onCancel });
138
- // Check if user cancelled - token can be empty if keeping existing
139
- const finalToken = response.token || existing.MATTERMOST_TOKEN;
140
- if (!response.url || !finalToken || !response.channelId) {
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) {
141
303
  console.log('');
142
- console.log(dim(' Setup incomplete. Run claude-threads again to retry.'));
143
- process.exit(1);
144
- }
145
- // Build .env content
146
- const envContent = `# claude-threads configuration
147
- # Generated by claude-threads onboarding
148
-
149
- # Mattermost server URL
150
- MATTERMOST_URL=${response.url}
151
-
152
- # Bot token (from Integrations > Bot Accounts)
153
- MATTERMOST_TOKEN=${finalToken}
154
-
155
- # Channel ID where the bot listens
156
- MATTERMOST_CHANNEL_ID=${response.channelId}
157
-
158
- # Bot mention name (users @mention this)
159
- MATTERMOST_BOT_NAME=${response.botName || 'claude-code'}
160
-
161
- # Allowed usernames (comma-separated, empty = all users)
162
- ALLOWED_USERS=${response.allowedUsers || ''}
163
-
164
- # Skip permission prompts (true = auto-approve, false = require emoji approval)
165
- SKIP_PERMISSIONS=${response.skipPermissions ? 'true' : 'false'}
166
-
167
- # Chrome integration (requires Claude in Chrome extension)
168
- CLAUDE_CHROME=${response.chrome ? 'true' : 'false'}
169
-
170
- # Git worktree mode (off, prompt, require)
171
- WORKTREE_MODE=${response.worktreeMode || 'prompt'}
172
- `;
173
- // Save to same location if reconfiguring, otherwise default location
174
- const defaultConfigDir = resolve(homedir(), '.config', 'claude-threads');
175
- const defaultEnvPath = resolve(defaultConfigDir, '.env');
176
- const envPath = existingPath || defaultEnvPath;
177
- const configDir = dirname(envPath);
178
- try {
179
- mkdirSync(configDir, { recursive: true });
180
- writeFileSync(envPath, envContent, { mode: 0o600 }); // Secure permissions
181
- }
182
- catch (err) {
183
- console.error('');
184
- console.error(` Failed to save config: ${err}`);
304
+ console.log(dim(' ⚠️ Both tokens are required. Setup cancelled.'));
185
305
  process.exit(1);
186
306
  }
187
- console.log('');
188
- console.log(green(' ✓ Configuration saved!'));
189
- console.log(dim(` ${envPath}`));
190
- console.log('');
191
- console.log(dim(' Starting claude-threads...'));
192
- console.log('');
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
+ };
193
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) {