@thesammykins/tether 1.2.0 → 1.3.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
@@ -111,6 +111,8 @@ tether config list
111
111
  | Key | Default | Description |
112
112
  |-----|---------|-------------|
113
113
  | `ENABLE_DMS` | `false` | Allow the bot to respond to direct messages |
114
+ | `FORUM_SESSIONS` | `false` | Use forum channel posts instead of text channel threads for sessions |
115
+ | `FORUM_CHANNEL_ID` | (empty) | Discord forum channel ID for session posts (required when `FORUM_SESSIONS=true`) |
114
116
 
115
117
  ### Database
116
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thesammykins/tether",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Discord bot that bridges messages to AI agent sessions (Claude, OpenCode, Codex)",
5
5
  "license": "MIT",
6
6
  "author": "thesammykins",
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,
@@ -40,6 +41,10 @@ import { questionResponses, pendingTypedAnswers } from './api.js';
40
41
  // DM support - opt-in via env var (disabled by default for security)
41
42
  const ENABLE_DMS = process.env.ENABLE_DMS === 'true';
42
43
 
44
+ // Forum session support - create sessions as forum posts instead of text threads
45
+ const FORUM_SESSIONS = process.env.FORUM_SESSIONS === 'true';
46
+ const FORUM_CHANNEL_ID = process.env.FORUM_CHANNEL_ID || '';
47
+
43
48
  // Allowed working directories (configurable via env, comma-separated)
44
49
  // If not set, any existing directory is allowed (backward compatible)
45
50
  const ALLOWED_DIRS = process.env.CORD_ALLOWED_DIRS
@@ -536,30 +541,57 @@ client.on(Events.MessageCreate, async (message: Message) => {
536
541
  let thread;
537
542
  let channelContext = '';
538
543
  try {
539
- // Post status message in the channel
540
- statusMessage = await (message.channel as TextChannel).send('Processing...');
544
+ if (FORUM_SESSIONS && FORUM_CHANNEL_ID) {
545
+ // Forum mode: create a forum post in the configured forum channel
546
+ const forumChannel = await client.channels.fetch(FORUM_CHANNEL_ID);
547
+ if (!forumChannel || forumChannel.type !== ChannelType.GuildForum) {
548
+ log(`FORUM_CHANNEL_ID ${FORUM_CHANNEL_ID} is not a forum channel`);
549
+ await message.reply('Forum channel is misconfigured. Check FORUM_CHANNEL_ID.');
550
+ return;
551
+ }
541
552
 
542
- // Generate thread name from cleaned message content
543
- const threadName = generateThreadName(cleanedMessage);
553
+ const threadName = generateThreadName(cleanedMessage);
544
554
 
545
- // Create thread from the status message
546
- thread = await statusMessage.startThread({
547
- name: threadName,
548
- autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
549
- });
555
+ // Forum posts require an initial message (unlike text threads)
556
+ thread = await (forumChannel as ForumChannel).threads.create({
557
+ name: threadName,
558
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
559
+ message: {
560
+ content: `**${message.author.tag}:** ${cleanedMessage}`,
561
+ },
562
+ });
550
563
 
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
- }
564
+ // Reply in the original channel with a link to the forum post
565
+ await message.reply(`šŸ“‹ Session started: <#${thread.id}>`);
566
+
567
+ // Get context from the source channel (not the forum)
568
+ channelContext = await getChannelContext(message.channel as TextChannel);
569
+ if (channelContext) {
570
+ log(`Channel context: ${channelContext.slice(0, 100)}...`);
571
+ }
572
+ } else {
573
+ // Default mode: create a text thread from a status message
574
+ statusMessage = await (message.channel as TextChannel).send('Processing...');
556
575
 
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}`);
576
+ const threadName = generateThreadName(cleanedMessage);
577
+
578
+ thread = await statusMessage.startThread({
579
+ name: threadName,
580
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
581
+ });
582
+
583
+ // Feature: Get channel context for new conversations
584
+ channelContext = await getChannelContext(message.channel as TextChannel);
585
+ if (channelContext) {
586
+ log(`Channel context: ${channelContext.slice(0, 100)}...`);
587
+ }
588
+
589
+ // Copy the original message content into the thread for context
590
+ const originalMessages = await message.channel.messages.fetch({ limit: 10 });
591
+ const userMessage = originalMessages.find(m => m.id === message.id);
592
+ if (userMessage) {
593
+ await thread.send(`**${message.author.tag}:** ${cleanedMessage}`);
594
+ }
563
595
  }
564
596
  } catch (error) {
565
597
  log(`Failed to create thread: ${error}`);
@@ -631,10 +663,23 @@ client.on(Events.MessageReactionAdd, async (reaction, user) => {
631
663
 
632
664
  log(`āœ… reaction on last message in thread ${thread.id}`);
633
665
 
634
- // Update thread starter message to "Done"
635
- // The thread ID equals the starter message ID (thread was created from that message)
666
+ // Determine parent channel type for the correct "Done" update
636
667
  const parentChannel = await client.channels.fetch(parentChannelId);
637
- if (parentChannel?.isTextBased()) {
668
+
669
+ if (parentChannel?.type === ChannelType.GuildForum) {
670
+ // Forum thread: edit the starter message inside the thread
671
+ try {
672
+ const starterMessage = await thread.fetchStarterMessage();
673
+ if (starterMessage) {
674
+ await starterMessage.edit(`${starterMessage.content}\n\nāœ… Done`);
675
+ log(`Forum thread ${thread.id} marked as Done`);
676
+ }
677
+ } catch (error) {
678
+ log(`Failed to edit forum starter message: ${error}`);
679
+ }
680
+ } else if (parentChannel?.isTextBased()) {
681
+ // Text thread: edit the status message in the parent channel
682
+ // The thread ID equals the starter message ID (thread was created from that message)
638
683
  const starterMessage = await (parentChannel as TextChannel).messages.fetch(thread.id);
639
684
  await starterMessage.edit('āœ… Done');
640
685
  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' },