@thesammykins/tether 1.2.0 → 1.4.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/bin/tether.ts CHANGED
@@ -817,18 +817,46 @@ async function start() {
817
817
 
818
818
  console.log('Starting Tether...\n');
819
819
 
820
+ // Resolve script paths relative to the package root, not process.cwd().
821
+ // When installed globally or via npx, cwd is the user's project dir where
822
+ // src/bot.ts doesn't exist. import.meta.dir is bin/, so one level up is root.
823
+ const packageRoot = dirname(import.meta.dir);
824
+ const botScript = join(packageRoot, 'src', 'bot.ts');
825
+ const workerScript = join(packageRoot, 'src', 'worker.ts');
826
+
827
+ // Load config store values into child process environment.
828
+ // Secrets are encrypted on disk — decrypt them so bot/worker can read
829
+ // DISCORD_BOT_TOKEN etc. from process.env as they expect.
830
+ const childEnv: Record<string, string | undefined> = { ...process.env };
831
+ const prefs = readPreferences();
832
+ for (const [key, value] of Object.entries(prefs)) {
833
+ if (!childEnv[key]) childEnv[key] = value;
834
+ }
835
+ if (hasSecrets()) {
836
+ const pw = await promptPassword('Encryption password: ');
837
+ try {
838
+ const secrets = readSecrets(pw);
839
+ for (const [key, value] of Object.entries(secrets)) {
840
+ if (!childEnv[key]) childEnv[key] = value;
841
+ }
842
+ } catch {
843
+ console.error('Wrong password or corrupted secrets file.');
844
+ process.exit(1);
845
+ }
846
+ }
847
+
820
848
  // Start bot
821
- const bot = spawn(['bun', 'run', 'src/bot.ts'], {
849
+ const bot = spawn(['bun', 'run', botScript], {
822
850
  stdout: 'inherit',
823
851
  stderr: 'inherit',
824
- cwd: process.cwd(),
852
+ env: childEnv,
825
853
  });
826
854
 
827
855
  // Start worker
828
- const worker = spawn(['bun', 'run', 'src/worker.ts'], {
856
+ const worker = spawn(['bun', 'run', workerScript], {
829
857
  stdout: 'inherit',
830
858
  stderr: 'inherit',
831
- cwd: process.cwd(),
859
+ env: childEnv,
832
860
  });
833
861
 
834
862
  // Save PIDs
@@ -982,7 +1010,12 @@ async function configCommand() {
982
1010
  console.error('Password cannot be empty');
983
1011
  process.exit(1);
984
1012
  }
985
- writeSecret(key, value, pw);
1013
+ try {
1014
+ writeSecret(key, value, pw);
1015
+ } catch (err) {
1016
+ console.error(err instanceof Error ? err.message : 'Failed to save secret');
1017
+ process.exit(1);
1018
+ }
986
1019
  console.log(`✔ Secret "${key}" saved (encrypted)`);
987
1020
  } else {
988
1021
  if (value === undefined) {
@@ -37,6 +37,20 @@ The bot handles Discord events, the queue provides durability and backpressure,
37
37
  6. Adapter runs the CLI (`claude`/`opencode`/`codex`) with the prompt
38
38
  7. Worker posts the agent's response back to the Discord thread
39
39
 
40
+ ### Forum Channels
41
+
42
+ When `FORUM_SESSIONS=true` and `FORUM_CHANNEL_ID` is set:
43
+
44
+ 1. User `@mentions` the bot in any channel
45
+ 2. Bot runs the middleware pipeline (allowlist → rate limiter → pause check)
46
+ 3. Bot creates a **forum post** in the configured forum channel (instead of a thread)
47
+ 4. Bot replies in the original channel with a link to the forum post
48
+ 5. Bot adds a job to the BullMQ queue
49
+ 6. Worker posts the response in the forum post
50
+ 7. Follow-up messages in the forum post continue the same session
51
+
52
+ Forum threads use the same `thread_id → session_id` DB mapping as regular threads — no schema differences.
53
+
40
54
  ### Direct Messages
41
55
 
42
56
  1. User sends a message to the bot in DMs (no `@mention` needed)
@@ -88,13 +88,67 @@ tether config list
88
88
  | `REDIS_HOST` | `localhost` | Redis host |
89
89
  | `REDIS_PORT` | `6379` | Redis port |
90
90
 
91
- ### Security
91
+ ### Security & Access Control
92
+
93
+ Tether supports restricting who can interact with the bot using allowlists. If none are configured, the bot responds to all users.
94
+
95
+ #### User Allowlist
96
+
97
+ Restrict the bot to only respond to specific Discord users:
98
+
99
+ ```bash
100
+ # Set allowed users (comma-separated Discord user IDs)
101
+ tether config set ALLOWED_USERS 123456789012345678,987654321098765432
102
+ ```
103
+
104
+ **How to find your Discord user ID:**
105
+
106
+ 1. Enable **Developer Mode** in Discord: Settings → App Settings → Advanced → Developer Mode
107
+ 2. Right-click on your username (or any user) → **Copy ID**
108
+
109
+ When `ALLOWED_USERS` is set, only users in the list can interact with the bot. This works in both guild channels and DMs.
110
+
111
+ #### Role and Channel Allowlists
112
+
113
+ For guild (server) deployments, you can also restrict by role or channel:
114
+
115
+ ```bash
116
+ # Only allow users with specific roles (comma-separated role IDs)
117
+ tether config set ALLOWED_ROLES 111111111111111111,222222222222222222
118
+
119
+ # Only allow bot usage in specific channels (comma-separated channel IDs)
120
+ tether config set ALLOWED_CHANNELS 333333333333333333,444444444444444444
121
+ ```
122
+
123
+ **How these work together:**
124
+ - If `ALLOWED_CHANNELS` is set, messages must be in an allowed channel (or its threads)
125
+ - If `ALLOWED_USERS` or `ALLOWED_ROLES` is set, the user must match at least one:
126
+ - Be in the `ALLOWED_USERS` list, OR
127
+ - Have a role in the `ALLOWED_ROLES` list
128
+ - Role and channel allowlists only apply in guilds (not DMs)
129
+ - If no allowlists are configured, the bot responds to everyone
130
+
131
+ **Examples:**
132
+
133
+ ```bash
134
+ # Only respond to yourself
135
+ tether config set ALLOWED_USERS 123456789012345678
136
+
137
+ # Only respond in a specific channel
138
+ tether config set ALLOWED_CHANNELS 987654321098765432
139
+
140
+ # Only respond to admins (role ID)
141
+ tether config set ALLOWED_ROLES 555555555555555555
142
+
143
+ # Combine: only respond to specific users in specific channels
144
+ tether config set ALLOWED_USERS 123456789012345678
145
+ tether config set ALLOWED_CHANNELS 987654321098765432
146
+ ```
147
+
148
+ #### Directory Allowlist
92
149
 
93
150
  | Key | Default | Description |
94
151
  |-----|---------|-------------|
95
- | `ALLOWED_USERS` | (empty = all) | Comma-separated Discord user IDs |
96
- | `ALLOWED_ROLES` | (empty = all) | Comma-separated Discord role IDs (guild only) |
97
- | `ALLOWED_CHANNELS` | (empty = all) | Comma-separated Discord channel IDs (guild only) |
98
152
  | `CORD_ALLOWED_DIRS` | (empty = any) | Comma-separated allowed working directories |
99
153
 
100
154
  ### Limits
@@ -111,6 +165,19 @@ tether config list
111
165
  | Key | Default | Description |
112
166
  |-----|---------|-------------|
113
167
  | `ENABLE_DMS` | `false` | Allow the bot to respond to direct messages |
168
+ | `FORUM_SESSIONS` | `false` | Use forum channel posts instead of text channel threads for sessions |
169
+ | `FORUM_CHANNEL_ID` | (empty) | Discord forum channel ID for session posts (required when `FORUM_SESSIONS=true`) |
170
+
171
+ #### Forum Sessions
172
+
173
+ By default, Tether creates threads in the channel where the bot is mentioned. When forum sessions are enabled, it creates forum posts in a dedicated forum channel instead — useful for keeping conversations organized and searchable.
174
+
175
+ ```bash
176
+ tether config set FORUM_SESSIONS true
177
+ tether config set FORUM_CHANNEL_ID 123456789012345678
178
+ ```
179
+
180
+ Both keys are required. See [Discord Setup — Forum Channels](discord-setup.md#6-use-forum-channels-optional) for the full setup guide.
114
181
 
115
182
  ### Database
116
183
 
@@ -152,7 +219,4 @@ Paths outside the allowlist are rejected. If unset, any existing directory is al
152
219
 
153
220
  ## Finding Discord IDs
154
221
 
155
- To get user, role, or channel IDs:
156
-
157
- 1. Enable **Developer Mode** in Discord: Settings → App Settings → Advanced → Developer Mode
158
- 2. Right-click a user, role, or channel → **Copy ID**
222
+ See the [Security & Access Control](#security--access-control) section above for instructions on finding user, role, and channel IDs.
@@ -64,7 +64,34 @@ The bot needs these permissions in your server:
64
64
  4. Copy the generated URL and open it in your browser
65
65
  5. Select the server you want to add the bot to and click **Authorize**
66
66
 
67
- ## 6. Enable DMs (Optional)
67
+ ## 6. Use Forum Channels (Optional)
68
+
69
+ By default, Tether creates threads in the channel where the bot is mentioned. If you prefer organized, searchable forum posts instead:
70
+
71
+ 1. Create a **Forum Channel** in your Discord server (Server Settings → Channels → Create Channel → Forum)
72
+ 2. Configure Tether to use it:
73
+ ```bash
74
+ tether config set FORUM_SESSIONS true
75
+ tether config set FORUM_CHANNEL_ID 123456789012345678
76
+ ```
77
+ 3. Restart Tether (`tether stop && tether start`)
78
+
79
+ ### Forum Behavior
80
+
81
+ - When someone `@mentions` the bot in any channel, a new **forum post** is created in your forum channel instead of a thread
82
+ - The bot replies in the original channel with a link to the forum post
83
+ - Follow-up messages in the forum post continue the same session — no `@mention` needed
84
+ - Session reuse, pause/resume, BRB, and ✅ completion all work the same as regular threads
85
+ - The forum post title is auto-generated from the first prompt
86
+
87
+ ### Finding the Forum Channel ID
88
+
89
+ 1. Enable **Developer Mode** (see [Finding Discord IDs](#finding-discord-ids) below)
90
+ 2. Right-click the forum channel → **Copy ID**
91
+
92
+ > **Note:** The bot needs **Send Messages**, **Create Posts**, and **Send Messages in Threads** permissions in the forum channel.
93
+
94
+ ## 7. Enable DMs (Optional)
68
95
 
69
96
  If you want users to DM the bot directly:
70
97
 
@@ -82,7 +109,7 @@ If you want users to DM the bot directly:
82
109
  - Send `!reset` in DMs to start a fresh session
83
110
  - Only `ALLOWED_USERS` is checked for DMs (roles and channels don't apply)
84
111
 
85
- ## 7. Start and Test
112
+ ## 8. Start and Test
86
113
 
87
114
  ```bash
88
115
  # Start Redis (if not already running)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thesammykins/tether",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Discord bot that bridges messages to AI agent sessions (Claude, OpenCode, Codex)",
5
5
  "license": "MIT",
6
6
  "author": "thesammykins",
@@ -9,12 +9,114 @@ import type { AgentAdapter, SpawnOptions, SpawnResult } from './types.js';
9
9
  * - `--print`: Non-interactive mode, returns output
10
10
  * - `--session-id UUID`: Set session ID for new sessions
11
11
  * - `--resume UUID`: Resume an existing session (for follow-ups)
12
+ * - `--continue` / `-c`: Resume latest session in directory (fallback)
12
13
  * - `--append-system-prompt`: Inject context that survives compaction
13
14
  * - `-p "prompt"`: The actual prompt to send
14
15
  * - `--output-format json`: Structured output (if supported)
16
+ *
17
+ * Known Issues:
18
+ * - GitHub Issue #5012: `--resume` was broken in v1.0.67
19
+ * - Sessions are directory-scoped; must resume from same cwd
20
+ * - `--continue` is preferred fallback when `--resume` fails
15
21
  */
16
22
 
17
23
  const TIMEZONE = process.env.TZ || 'UTC';
24
+ const KNOWN_BUGGY_VERSIONS = ['1.0.67'];
25
+
26
+ // Cache resolved binary path
27
+ let cachedBinaryPath: string | null = null;
28
+
29
+ /**
30
+ * Resolve the Claude CLI binary path.
31
+ * Tries `which claude` (macOS/Linux) or `where.exe claude` (Windows),
32
+ * then falls back to checking `npx @anthropic-ai/claude-code` availability.
33
+ */
34
+ async function getClaudeBinaryPath(): Promise<string> {
35
+ if (cachedBinaryPath) {
36
+ return cachedBinaryPath;
37
+ }
38
+
39
+ const isWindows = process.platform === 'win32';
40
+ const whichCommand = isWindows ? 'where.exe' : 'which';
41
+
42
+ try {
43
+ const proc = Bun.spawn([whichCommand, 'claude'], {
44
+ stdout: 'pipe',
45
+ stderr: 'pipe',
46
+ });
47
+
48
+ const stdout = await new Response(proc.stdout).text();
49
+ const exitCode = await proc.exited;
50
+
51
+ if (exitCode === 0 && stdout.trim()) {
52
+ cachedBinaryPath = 'claude';
53
+ console.log(`[claude] Binary found at: ${stdout.trim()}`);
54
+ return cachedBinaryPath;
55
+ }
56
+ } catch (err) {
57
+ // Fall through to npx check
58
+ }
59
+
60
+ // Try npx fallback
61
+ try {
62
+ const proc = Bun.spawn(['npx', '--version'], {
63
+ stdout: 'pipe',
64
+ stderr: 'pipe',
65
+ });
66
+
67
+ const exitCode = await proc.exited;
68
+
69
+ if (exitCode === 0) {
70
+ cachedBinaryPath = 'npx';
71
+ console.log('[claude] Binary not in PATH, will use: npx @anthropic-ai/claude-code');
72
+ return cachedBinaryPath;
73
+ }
74
+ } catch (err) {
75
+ // Fall through to error
76
+ }
77
+
78
+ throw new Error(
79
+ 'Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code'
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Get the Claude CLI version.
85
+ * Runs `claude --version` and parses the output.
86
+ */
87
+ async function getClaudeVersion(binaryPath: string): Promise<string> {
88
+ const args = binaryPath === 'npx'
89
+ ? ['npx', '@anthropic-ai/claude-code', '--version']
90
+ : [binaryPath, '--version'];
91
+
92
+ try {
93
+ const proc = Bun.spawn(args, {
94
+ stdout: 'pipe',
95
+ stderr: 'pipe',
96
+ });
97
+
98
+ const stdout = await new Response(proc.stdout).text();
99
+ const exitCode = await proc.exited;
100
+
101
+ if (exitCode === 0) {
102
+ const version = stdout.trim();
103
+ console.log(`[claude] CLI version: ${version}`);
104
+
105
+ // Warn if known buggy version
106
+ if (KNOWN_BUGGY_VERSIONS.some((v) => version.includes(v))) {
107
+ console.warn(
108
+ `[claude] WARNING: Version ${version} has known issues with --resume (GitHub #5012)`
109
+ );
110
+ }
111
+
112
+ return version;
113
+ }
114
+ } catch (err) {
115
+ console.warn('[claude] Could not determine CLI version:', err);
116
+ }
117
+
118
+ return 'unknown';
119
+ }
18
120
 
19
121
  function getDatetimeContext(): string {
20
122
  const now = new Date();
@@ -38,8 +140,35 @@ export class ClaudeAdapter implements AgentAdapter {
38
140
 
39
141
  const cwd = workingDir || process.env.CLAUDE_WORKING_DIR || process.cwd();
40
142
 
143
+ // Resolve binary path and get version
144
+ const binaryPath = await getClaudeBinaryPath();
145
+ await getClaudeVersion(binaryPath);
146
+
41
147
  // Build CLI arguments
42
- const args = ['claude'];
148
+ const args = this.buildArgs(binaryPath, options);
149
+
150
+ console.log('[claude] Spawning with args:', args);
151
+ console.log('[claude] Working directory:', cwd);
152
+
153
+ // Spawn the process
154
+ const result = await this.spawnProcess(args, cwd, sessionId, resume);
155
+
156
+ return result;
157
+ }
158
+
159
+ /**
160
+ * Build CLI arguments based on options.
161
+ */
162
+ private buildArgs(binaryPath: string, options: SpawnOptions): string[] {
163
+ const { prompt, sessionId, resume, systemPrompt } = options;
164
+
165
+ const args: string[] = [];
166
+
167
+ if (binaryPath === 'npx') {
168
+ args.push('npx', '@anthropic-ai/claude-code');
169
+ } else {
170
+ args.push(binaryPath);
171
+ }
43
172
 
44
173
  // Non-interactive mode
45
174
  args.push('--print');
@@ -67,8 +196,19 @@ export class ClaudeAdapter implements AgentAdapter {
67
196
  // The actual prompt
68
197
  args.push('-p', prompt);
69
198
 
70
- // Spawn the process
71
- const proc = Bun.spawn(args, {
199
+ return args;
200
+ }
201
+
202
+ /**
203
+ * Spawn the Claude process and handle fallback for resume failures.
204
+ */
205
+ private async spawnProcess(
206
+ args: string[],
207
+ cwd: string,
208
+ sessionId: string,
209
+ resume: boolean
210
+ ): Promise<SpawnResult> {
211
+ let proc = Bun.spawn(args, {
72
212
  cwd,
73
213
  env: {
74
214
  ...process.env,
@@ -79,11 +219,61 @@ export class ClaudeAdapter implements AgentAdapter {
79
219
  });
80
220
 
81
221
  // Collect output
82
- const stdout = await new Response(proc.stdout).text();
83
- const stderr = await new Response(proc.stderr).text();
222
+ let stdout = await new Response(proc.stdout).text();
223
+ let stderr = await new Response(proc.stderr).text();
84
224
 
85
225
  // Wait for process to exit
86
- const exitCode = await proc.exited;
226
+ let exitCode = await proc.exited;
227
+
228
+ console.log('[claude] Exit code:', exitCode);
229
+ if (stderr) {
230
+ console.log('[claude] Stderr:', stderr);
231
+ }
232
+
233
+ // Handle --resume fallback
234
+ if (
235
+ resume &&
236
+ exitCode !== 0 &&
237
+ (stderr.includes('No conversation found') || stderr.includes('Session not found'))
238
+ ) {
239
+ console.log(
240
+ `[claude] --resume failed for ${sessionId}, falling back to --continue`
241
+ );
242
+
243
+ // Rebuild args with --continue instead of --resume
244
+ const continueArgs = args.map((arg, i) => {
245
+ if (arg === '--resume') {
246
+ return '--continue';
247
+ }
248
+ // Skip the session ID that follows --resume
249
+ if (i > 0 && args[i - 1] === '--resume') {
250
+ return null;
251
+ }
252
+ return arg;
253
+ }).filter((arg): arg is string => arg !== null);
254
+
255
+ console.log('[claude] Retrying with args:', continueArgs);
256
+
257
+ // Retry with --continue
258
+ proc = Bun.spawn(continueArgs, {
259
+ cwd,
260
+ env: {
261
+ ...process.env,
262
+ TZ: TIMEZONE,
263
+ },
264
+ stdout: 'pipe',
265
+ stderr: 'pipe',
266
+ });
267
+
268
+ stdout = await new Response(proc.stdout).text();
269
+ stderr = await new Response(proc.stderr).text();
270
+ exitCode = await proc.exited;
271
+
272
+ console.log('[claude] Fallback exit code:', exitCode);
273
+ if (stderr) {
274
+ console.log('[claude] Fallback stderr:', stderr);
275
+ }
276
+ }
87
277
 
88
278
  if (exitCode !== 0) {
89
279
  throw new Error(`Claude CLI failed (exit ${exitCode}): ${stderr || 'Unknown error'}`);
package/src/bot.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  TextChannel,
17
17
  DMChannel,
18
18
  ChannelType,
19
+ ForumChannel,
19
20
  Partials,
20
21
  ThreadAutoArchiveDuration,
21
22
  SlashCommandBuilder,
@@ -35,11 +36,16 @@ import { generateThreadName } from './features/thread-naming.js';
35
36
  import { checkSessionLimits } from './features/session-limits.js';
36
37
  import { handlePauseResume } from './features/pause-resume.js';
37
38
  import { isBrbMessage, isBackMessage, setBrb, setBack } from './features/brb.js';
39
+ import { listSessions, formatAge } from './features/sessions.js';
38
40
  import { questionResponses, pendingTypedAnswers } from './api.js';
39
41
 
40
42
  // DM support - opt-in via env var (disabled by default for security)
41
43
  const ENABLE_DMS = process.env.ENABLE_DMS === 'true';
42
44
 
45
+ // Forum session support - create sessions as forum posts instead of text threads
46
+ const FORUM_SESSIONS = process.env.FORUM_SESSIONS === 'true';
47
+ const FORUM_CHANNEL_ID = process.env.FORUM_CHANNEL_ID || '';
48
+
43
49
  // Allowed working directories (configurable via env, comma-separated)
44
50
  // If not set, any existing directory is allowed (backward compatible)
45
51
  const ALLOWED_DIRS = process.env.CORD_ALLOWED_DIRS
@@ -141,24 +147,43 @@ client.once(Events.ClientReady, async (c) => {
141
147
  const existingCommands = await c.application?.commands.fetch();
142
148
  const cordCommand = existingCommands?.find(cmd => cmd.name === 'cord');
143
149
 
144
- if (!cordCommand) {
145
- const command = new SlashCommandBuilder()
146
- .setName('cord')
147
- .setDescription('Configure Cord bot')
148
- .addSubcommand(sub =>
149
- sub.setName('config')
150
- .setDescription('Configure channel settings')
151
- .addStringOption(opt =>
152
- opt.setName('dir')
153
- .setDescription('Working directory for Claude in this channel')
154
- .setRequired(true)
155
- )
156
- );
150
+ // Always re-register to pick up new subcommands
151
+ const command = new SlashCommandBuilder()
152
+ .setName('cord')
153
+ .setDescription('Configure Cord bot')
154
+ .addSubcommand(sub =>
155
+ sub.setName('config')
156
+ .setDescription('Configure channel settings')
157
+ .addStringOption(opt =>
158
+ opt.setName('dir')
159
+ .setDescription('Working directory for Claude in this channel')
160
+ .setRequired(true)
161
+ )
162
+ )
163
+ .addSubcommand(sub =>
164
+ sub.setName('sessions')
165
+ .setDescription('List resumable Claude Code sessions')
166
+ .addStringOption(opt =>
167
+ opt.setName('dir')
168
+ .setDescription('Project directory to list sessions for (defaults to channel config)')
169
+ .setRequired(false)
170
+ )
171
+ .addIntegerOption(opt =>
172
+ opt.setName('limit')
173
+ .setDescription('Max sessions to show (default 5)')
174
+ .setRequired(false)
175
+ .setMinValue(1)
176
+ .setMaxValue(25)
177
+ )
178
+ );
157
179
 
180
+ if (!cordCommand) {
158
181
  await c.application?.commands.create(command);
159
182
  log('Slash commands registered');
160
183
  } else {
161
- log('Slash commands already registered');
184
+ // Update existing command to include new subcommands
185
+ await cordCommand.edit(command);
186
+ log('Slash commands updated');
162
187
  }
163
188
 
164
189
  // Start HTTP API server
@@ -199,6 +224,48 @@ client.on(Events.InteractionCreate, async (interaction: Interaction) => {
199
224
  });
200
225
  log(`Channel ${interaction.channelId} configured with working dir: ${dir}`);
201
226
  }
227
+
228
+ if (subcommand === 'sessions') {
229
+ // Resolve project directory: explicit option > channel config > env > cwd
230
+ let projectDir = interaction.options.getString('dir');
231
+ if (projectDir) {
232
+ if (projectDir.startsWith('~')) {
233
+ projectDir = projectDir.replace('~', homedir());
234
+ }
235
+ projectDir = resolve(projectDir);
236
+ } else {
237
+ const channelConfig = getChannelConfigCached(interaction.channelId);
238
+ projectDir = channelConfig?.working_dir
239
+ || process.env.CLAUDE_WORKING_DIR
240
+ || process.cwd();
241
+ }
242
+
243
+ const limit = interaction.options.getInteger('limit') ?? 5;
244
+ const sessions = listSessions(projectDir, limit);
245
+
246
+ if (sessions.length === 0) {
247
+ await interaction.reply({
248
+ content: `No Claude sessions found for \`${projectDir}\`.`,
249
+ ephemeral: true,
250
+ });
251
+ return;
252
+ }
253
+
254
+ // Format session list
255
+ const lines = sessions.map((s, i) => {
256
+ const age = formatAge(s.lastActivity);
257
+ const preview = s.firstMessage
258
+ ? s.firstMessage.slice(0, 80) + (s.firstMessage.length > 80 ? '…' : '')
259
+ : '(no messages)';
260
+ return `**${i + 1}.** \`${s.id.slice(0, 8)}…\` — ${age} ago, ${s.messageCount} msgs\n> ${preview}`;
261
+ });
262
+
263
+ await interaction.reply({
264
+ content: `**Claude Sessions** for \`${projectDir}\`\n\n${lines.join('\n\n')}`,
265
+ ephemeral: true,
266
+ });
267
+ log(`Listed ${sessions.length} sessions for ${projectDir}`);
268
+ }
202
269
  return;
203
270
  }
204
271
 
@@ -536,30 +603,57 @@ client.on(Events.MessageCreate, async (message: Message) => {
536
603
  let thread;
537
604
  let channelContext = '';
538
605
  try {
539
- // Post status message in the channel
540
- statusMessage = await (message.channel as TextChannel).send('Processing...');
606
+ if (FORUM_SESSIONS && FORUM_CHANNEL_ID) {
607
+ // Forum mode: create a forum post in the configured forum channel
608
+ const forumChannel = await client.channels.fetch(FORUM_CHANNEL_ID);
609
+ if (!forumChannel || forumChannel.type !== ChannelType.GuildForum) {
610
+ log(`FORUM_CHANNEL_ID ${FORUM_CHANNEL_ID} is not a forum channel`);
611
+ await message.reply('Forum channel is misconfigured. Check FORUM_CHANNEL_ID.');
612
+ return;
613
+ }
541
614
 
542
- // Generate thread name from cleaned message content
543
- const threadName = generateThreadName(cleanedMessage);
615
+ const threadName = generateThreadName(cleanedMessage);
544
616
 
545
- // Create thread from the status message
546
- thread = await statusMessage.startThread({
547
- name: threadName,
548
- autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
549
- });
617
+ // Forum posts require an initial message (unlike text threads)
618
+ thread = await (forumChannel as ForumChannel).threads.create({
619
+ name: threadName,
620
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
621
+ message: {
622
+ content: `**${message.author.tag}:** ${cleanedMessage}`,
623
+ },
624
+ });
550
625
 
551
- // Feature: Get channel context for new conversations
552
- channelContext = await getChannelContext(message.channel as TextChannel);
553
- if (channelContext) {
554
- log(`Channel context: ${channelContext.slice(0, 100)}...`);
555
- }
626
+ // Reply in the original channel with a link to the forum post
627
+ await message.reply(`📋 Session started: <#${thread.id}>`);
628
+
629
+ // Get context from the source channel (not the forum)
630
+ channelContext = await getChannelContext(message.channel as TextChannel);
631
+ if (channelContext) {
632
+ log(`Channel context: ${channelContext.slice(0, 100)}...`);
633
+ }
634
+ } else {
635
+ // Default mode: create a text thread from a status message
636
+ statusMessage = await (message.channel as TextChannel).send('Processing...');
556
637
 
557
- // Copy the original message content into the thread for context
558
- // (excluding the bot mention and the status message)
559
- const originalMessages = await message.channel.messages.fetch({ limit: 10 });
560
- const userMessage = originalMessages.find(m => m.id === message.id);
561
- if (userMessage) {
562
- await thread.send(`**${message.author.tag}:** ${cleanedMessage}`);
638
+ const threadName = generateThreadName(cleanedMessage);
639
+
640
+ thread = await statusMessage.startThread({
641
+ name: threadName,
642
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
643
+ });
644
+
645
+ // Feature: Get channel context for new conversations
646
+ channelContext = await getChannelContext(message.channel as TextChannel);
647
+ if (channelContext) {
648
+ log(`Channel context: ${channelContext.slice(0, 100)}...`);
649
+ }
650
+
651
+ // Copy the original message content into the thread for context
652
+ const originalMessages = await message.channel.messages.fetch({ limit: 10 });
653
+ const userMessage = originalMessages.find(m => m.id === message.id);
654
+ if (userMessage) {
655
+ await thread.send(`**${message.author.tag}:** ${cleanedMessage}`);
656
+ }
563
657
  }
564
658
  } catch (error) {
565
659
  log(`Failed to create thread: ${error}`);
@@ -631,10 +725,23 @@ client.on(Events.MessageReactionAdd, async (reaction, user) => {
631
725
 
632
726
  log(`✅ reaction on last message in thread ${thread.id}`);
633
727
 
634
- // Update thread starter message to "Done"
635
- // The thread ID equals the starter message ID (thread was created from that message)
728
+ // Determine parent channel type for the correct "Done" update
636
729
  const parentChannel = await client.channels.fetch(parentChannelId);
637
- if (parentChannel?.isTextBased()) {
730
+
731
+ if (parentChannel?.type === ChannelType.GuildForum) {
732
+ // Forum thread: edit the starter message inside the thread
733
+ try {
734
+ const starterMessage = await thread.fetchStarterMessage();
735
+ if (starterMessage) {
736
+ await starterMessage.edit(`${starterMessage.content}\n\n✅ Done`);
737
+ log(`Forum thread ${thread.id} marked as Done`);
738
+ }
739
+ } catch (error) {
740
+ log(`Failed to edit forum starter message: ${error}`);
741
+ }
742
+ } else if (parentChannel?.isTextBased()) {
743
+ // Text thread: edit the status message in the parent channel
744
+ // The thread ID equals the starter message ID (thread was created from that message)
638
745
  const starterMessage = await (parentChannel as TextChannel).messages.fetch(thread.id);
639
746
  await starterMessage.edit('✅ Done');
640
747
  log(`Thread ${thread.id} marked as Done`);
package/src/config.ts CHANGED
@@ -81,6 +81,8 @@ const CONFIG_KEYS: Record<string, ConfigKeyMeta> = {
81
81
 
82
82
  // Features
83
83
  ENABLE_DMS: { section: 'features', default: 'false', description: 'Enable direct message support' },
84
+ FORUM_SESSIONS: { section: 'features', default: 'false', description: 'Use forum channel posts instead of text channel threads' },
85
+ FORUM_CHANNEL_ID: { section: 'features', default: '', description: 'Discord forum channel ID for session posts' },
84
86
 
85
87
  // Database
86
88
  DB_PATH: { section: 'database', default: './data/threads.db', description: 'SQLite database path' },
@@ -224,7 +226,11 @@ export function writeSecret(key: string, value: string, password: string): void
224
226
 
225
227
  let secrets: Record<string, string> = {};
226
228
  if (existsSync(getSecretsPath())) {
227
- secrets = readSecrets(password);
229
+ try {
230
+ secrets = readSecrets(password);
231
+ } catch {
232
+ throw new Error('Wrong password. Use the same password you set previously, or delete ~/.config/tether/secrets.enc to start fresh.');
233
+ }
228
234
  }
229
235
 
230
236
  secrets[key] = value;
@@ -238,7 +244,12 @@ export function deleteKey(key: string, password?: string): boolean {
238
244
  if (!password) throw new Error('Password required to modify secrets');
239
245
  if (!existsSync(getSecretsPath())) return false;
240
246
 
241
- const secrets = readSecrets(password);
247
+ let secrets: Record<string, string>;
248
+ try {
249
+ secrets = readSecrets(password);
250
+ } catch {
251
+ throw new Error('Wrong password. Use the same password you set previously.');
252
+ }
242
253
  if (!(key in secrets)) return false;
243
254
  delete secrets[key];
244
255
 
@@ -0,0 +1,201 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ export interface SessionInfo {
6
+ id: string;
7
+ createdAt: Date;
8
+ lastActivity: Date;
9
+ messageCount: number;
10
+ firstMessage: string;
11
+ cwd: string;
12
+ }
13
+
14
+ interface SessionLine {
15
+ type?: string;
16
+ sessionId?: string;
17
+ uuid?: string;
18
+ parentUuid?: string | null;
19
+ cwd?: string;
20
+ timestamp?: string;
21
+ message?: {
22
+ role?: string;
23
+ content?: string | Array<{ type: string; text?: string }>;
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Convert a filesystem path to Claude's sanitized directory name.
29
+ * Claude replaces `/`, `\`, and `:` with `-`.
30
+ *
31
+ * Examples:
32
+ * - macOS: `/Users/sam/project` → `-Users-sam-project`
33
+ * - Windows: `C:\Github\project` → `C--Github-project`
34
+ * - Linux: `/home/user/project` → `-home-user-project`
35
+ */
36
+ export function sanitizePath(projectPath: string): string {
37
+ return projectPath.replace(/[/\\:]/g, '-');
38
+ }
39
+
40
+ /**
41
+ * Return the full path to Claude's sessions directory for a given project path.
42
+ */
43
+ export function getSessionsDir(projectPath: string): string {
44
+ const sanitized = sanitizePath(projectPath);
45
+ return join(homedir(), '.claude', 'projects', sanitized);
46
+ }
47
+
48
+ /**
49
+ * Parse a JSONL session file and extract metadata.
50
+ * Returns null if the file doesn't exist, is empty, or cannot be parsed.
51
+ */
52
+ export function parseSessionFile(filePath: string): SessionInfo | null {
53
+ if (!existsSync(filePath)) {
54
+ return null;
55
+ }
56
+
57
+ try {
58
+ const content = readFileSync(filePath, 'utf-8').trim();
59
+ if (!content) {
60
+ return null;
61
+ }
62
+
63
+ const lines = content.split('\n');
64
+ const messages: SessionLine[] = [];
65
+
66
+ // Parse each line, skipping corrupted ones
67
+ for (const line of lines) {
68
+ if (!line.trim()) {
69
+ continue;
70
+ }
71
+
72
+ try {
73
+ const parsed = JSON.parse(line) as SessionLine;
74
+ messages.push(parsed);
75
+ } catch {
76
+ // Skip corrupted lines
77
+ continue;
78
+ }
79
+ }
80
+
81
+ if (messages.length === 0) {
82
+ return null;
83
+ }
84
+
85
+ // Extract metadata
86
+ const firstMsg = messages[0];
87
+ if (!firstMsg) {
88
+ return null;
89
+ }
90
+ const lastMsg = messages[messages.length - 1];
91
+ if (!lastMsg) {
92
+ return null;
93
+ }
94
+
95
+ // Get session ID
96
+ const sessionId = firstMsg.sessionId || firstMsg.uuid || 'unknown';
97
+
98
+ // Get CWD (usually from first message)
99
+ const cwd = firstMsg.cwd || '';
100
+
101
+ // Get timestamps
102
+ const createdAt = firstMsg.timestamp ? new Date(firstMsg.timestamp) : new Date(0);
103
+ const lastActivity = lastMsg.timestamp ? new Date(lastMsg.timestamp) : createdAt;
104
+
105
+ // Get first user message content
106
+ let firstMessage = '';
107
+ for (const msg of messages) {
108
+ if (msg.type === 'user' && msg.message?.content) {
109
+ const content = msg.message.content;
110
+ if (typeof content === 'string') {
111
+ firstMessage = content;
112
+ } else if (Array.isArray(content)) {
113
+ // Handle array of content blocks
114
+ const textBlock = content.find(block => block.type === 'text' && block.text);
115
+ if (textBlock && textBlock.text) {
116
+ firstMessage = textBlock.text;
117
+ }
118
+ }
119
+ break;
120
+ }
121
+ }
122
+
123
+ return {
124
+ id: sessionId,
125
+ createdAt,
126
+ lastActivity,
127
+ messageCount: messages.length,
128
+ firstMessage,
129
+ cwd,
130
+ };
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * List all sessions for a project path, sorted by lastActivity descending.
138
+ * Returns empty array if the sessions directory doesn't exist.
139
+ */
140
+ export function listSessions(projectPath: string, limit?: number): SessionInfo[] {
141
+ const sessionsDir = getSessionsDir(projectPath);
142
+
143
+ if (!existsSync(sessionsDir)) {
144
+ return [];
145
+ }
146
+
147
+ try {
148
+ const files = readdirSync(sessionsDir);
149
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
150
+
151
+ const sessions: SessionInfo[] = [];
152
+
153
+ for (const file of jsonlFiles) {
154
+ const filePath = join(sessionsDir, file);
155
+
156
+ // Skip if not a file
157
+ try {
158
+ const stats = statSync(filePath);
159
+ if (!stats.isFile()) {
160
+ continue;
161
+ }
162
+ } catch {
163
+ continue;
164
+ }
165
+
166
+ const session = parseSessionFile(filePath);
167
+ if (session) {
168
+ sessions.push(session);
169
+ }
170
+ }
171
+
172
+ // Sort by lastActivity descending (newest first)
173
+ sessions.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
174
+
175
+ // Apply limit if specified
176
+ if (limit !== undefined && limit > 0) {
177
+ return sessions.slice(0, limit);
178
+ }
179
+
180
+ return sessions;
181
+ } catch {
182
+ return [];
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Format a date as a human-readable relative time string.
188
+ * e.g. "2m", "3h", "1d", "2w"
189
+ */
190
+ export function formatAge(date: Date): string {
191
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
192
+ if (seconds < 60) return `${seconds}s`;
193
+ const minutes = Math.floor(seconds / 60);
194
+ if (minutes < 60) return `${minutes}m`;
195
+ const hours = Math.floor(minutes / 60);
196
+ if (hours < 24) return `${hours}h`;
197
+ const days = Math.floor(hours / 24);
198
+ if (days < 7) return `${days}d`;
199
+ const weeks = Math.floor(days / 7);
200
+ return `${weeks}w`;
201
+ }