remote-opencode 1.0.2 β 1.0.4
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/README.md +99 -48
- package/dist/src/bot.js +7 -1
- package/dist/src/cli.js +10 -1
- package/dist/src/commands/autowork.js +41 -0
- package/dist/src/commands/code.js +47 -0
- package/dist/src/commands/index.js +4 -0
- package/dist/src/commands/opencode.js +41 -2
- package/dist/src/handlers/messageHandler.js +196 -0
- package/dist/src/services/dataStore.js +56 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Control your AI coding assistant from anywhere β your phone, tablet, or another computer.
|
|
4
4
|
|
|
5
|
+
<div align="center">
|
|
6
|
+
<img width="1024" alt="Gemini_Generated_Image_47d5gq47d5gq47d5" src="https://github.com/user-attachments/assets/1defa11d-6195-4a9c-956b-4f87470f6393" />
|
|
7
|
+
</div>
|
|
8
|
+
|
|
5
9
|
**remote-opencode** is a Discord bot that bridges your local [OpenCode CLI](https://github.com/sst/opencode) to Discord, enabling you to interact with your AI coding assistant remotely. Perfect for developers who want to:
|
|
6
10
|
|
|
7
11
|
- π± **Code from mobile** β Send coding tasks from your phone while away from your desk
|
|
@@ -75,7 +79,7 @@ npx remote-opencode
|
|
|
75
79
|
### Install from source
|
|
76
80
|
|
|
77
81
|
```bash
|
|
78
|
-
git clone https://github.com
|
|
82
|
+
git clone https://github.com/RoundTable02/remote-opencode.git
|
|
79
83
|
cd remote-opencode
|
|
80
84
|
npm install
|
|
81
85
|
npm run build
|
|
@@ -109,9 +113,7 @@ Before using remote-opencode, you need to create a Discord Application and Bot.
|
|
|
109
113
|
3. Enter a name (e.g., "Remote OpenCode")
|
|
110
114
|
4. Copy the **Application ID** β you'll need this later
|
|
111
115
|
|
|
112
|
-
|
|
113
|
-
<!--  -->
|
|
114
|
-
*Screenshot placeholder: Discord Developer Portal showing "New Application" button*
|
|
116
|
+
<img width="800" alt="image" src="https://github.com/user-attachments/assets/ca2e7ff3-91e7-4d66-93dc-c166189c0107" />
|
|
115
117
|
|
|
116
118
|
### Step 2: Create Bot & Get Token
|
|
117
119
|
|
|
@@ -120,9 +122,6 @@ Before using remote-opencode, you need to create a Discord Application and Bot.
|
|
|
120
122
|
3. **Copy the token immediately** β it's only shown once!
|
|
121
123
|
4. Keep this token secret β never share it publicly
|
|
122
124
|
|
|
123
|
-
<!-- SCREENSHOT: Bot section showing Reset Token button -->
|
|
124
|
-
<!--  -->
|
|
125
|
-
*Screenshot placeholder: Bot settings page with token section highlighted*
|
|
126
125
|
|
|
127
126
|
### Step 3: Enable Required Intents
|
|
128
127
|
|
|
@@ -133,9 +132,7 @@ Still in the **"Bot"** section, scroll down to **"Privileged Gateway Intents"**
|
|
|
133
132
|
|
|
134
133
|
Click **"Save Changes"**
|
|
135
134
|
|
|
136
|
-
|
|
137
|
-
<!--  -->
|
|
138
|
-
*Screenshot placeholder: Intents section with required toggles enabled*
|
|
135
|
+
<img width="1500" alt="image" src="https://github.com/user-attachments/assets/d20406ff-26ad-4204-9771-b157c340846a" />
|
|
139
136
|
|
|
140
137
|
### Step 4: Configure Bot Permissions
|
|
141
138
|
|
|
@@ -164,35 +161,15 @@ The bot needs specific permissions to function properly. You can configure permi
|
|
|
164
161
|
|
|
165
162
|
5. Copy the generated URL at the bottom β this is your bot invite link!
|
|
166
163
|
|
|
167
|
-
<!-- SCREENSHOT: OAuth2 URL Generator showing selected permissions -->
|
|
168
|
-
<!--  -->
|
|
169
|
-
*Screenshot placeholder: OAuth2 URL Generator with bot permissions selected*
|
|
170
164
|
|
|
171
165
|
#### Option B: Manual Permission Calculation
|
|
172
166
|
|
|
173
167
|
If you're building the URL manually, use this permission value:
|
|
174
168
|
|
|
175
169
|
```
|
|
176
|
-
https://discord.com/api/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=
|
|
170
|
+
https://discord.com/api/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=311385214016&integration_type=0&scope=bot+applications.commands
|
|
177
171
|
```
|
|
178
172
|
|
|
179
|
-
**Permission Breakdown (343932274624):**
|
|
180
|
-
|
|
181
|
-
| Permission | Value | Purpose |
|
|
182
|
-
|------------|-------|---------|
|
|
183
|
-
| View Channels | 1024 | Access channels and read messages |
|
|
184
|
-
| Send Messages | 2048 | Send messages to channels |
|
|
185
|
-
| Create Public Threads | 68719476736 | Create threads for each `/opencode` command |
|
|
186
|
-
| Send Messages in Threads | 274877906944 | Reply within created threads |
|
|
187
|
-
| Embed Links | 16384 | Send rich embed messages with formatting |
|
|
188
|
-
| Read Message History | 65536 | Access previous messages for context |
|
|
189
|
-
| Add Reactions | 64 | Add button components (uses emoji reactions) |
|
|
190
|
-
| Use Slash Commands | 2147483648 | Register and respond to slash commands |
|
|
191
|
-
|
|
192
|
-
**Optional Permissions (not required):**
|
|
193
|
-
- **Attach Files** (32768) β Only if you want to upload files
|
|
194
|
-
- **Mention @everyone** (131072) β Only if bot needs to ping everyone
|
|
195
|
-
|
|
196
173
|
**Important:** The URL must include `applications.commands` scope for slash commands to work!
|
|
197
174
|
|
|
198
175
|
### Step 5: Get Your Server (Guild) ID
|
|
@@ -202,25 +179,20 @@ https://discord.com/api/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=34
|
|
|
202
179
|
3. Right-click on your server name in the sidebar
|
|
203
180
|
4. Click **"Copy Server ID"**
|
|
204
181
|
|
|
205
|
-
|
|
206
|
-
<!--  -->
|
|
207
|
-
*Screenshot placeholder: Right-click menu showing "Copy Server ID" option*
|
|
182
|
+
<img width="184" height="530" alt="αα
³αα
³α
α
΅α«αα
£αΊ 2026-02-03 αα
©αα
₯α« 2 34 31" src="https://github.com/user-attachments/assets/8ecc2a28-05e5-494f-834f-95d9d0e4e730" />
|
|
208
183
|
|
|
209
184
|
### Step 6: Invite Bot to Your Server
|
|
210
185
|
|
|
211
186
|
Use the URL generated in Step 4 (OAuth2 URL Generator), or construct it manually:
|
|
212
187
|
|
|
213
188
|
```
|
|
214
|
-
https://discord.com/api/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=
|
|
189
|
+
https://discord.com/api/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=218900185540&scope=bot%20applications.commands
|
|
215
190
|
```
|
|
216
191
|
|
|
217
192
|
1. Replace `YOUR_CLIENT_ID` with your Application ID
|
|
218
193
|
2. Open the URL in your browser
|
|
219
194
|
3. Select your server and authorize
|
|
220
195
|
|
|
221
|
-
<!-- SCREENSHOT: Bot authorization page -->
|
|
222
|
-
<!--  -->
|
|
223
|
-
*Screenshot placeholder: Discord OAuth2 authorization page*
|
|
224
196
|
|
|
225
197
|
---
|
|
226
198
|
|
|
@@ -306,6 +278,77 @@ Start isolated work on a new branch with its own worktree.
|
|
|
306
278
|
|
|
307
279
|
This is perfect for working on multiple features simultaneously without branch switching.
|
|
308
280
|
|
|
281
|
+
### `/code` β Toggle Passthrough Mode
|
|
282
|
+
|
|
283
|
+
Enable passthrough mode in a thread to send messages directly to OpenCode without slash commands.
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
/code
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**How it works:**
|
|
290
|
+
1. Run `/code` in any thread to enable passthrough mode
|
|
291
|
+
2. Type messages naturally β they're sent directly to OpenCode
|
|
292
|
+
3. Run `/code` again to disable
|
|
293
|
+
|
|
294
|
+
**Example:**
|
|
295
|
+
```
|
|
296
|
+
You: /code
|
|
297
|
+
Bot: β
Passthrough mode enabled for this thread.
|
|
298
|
+
Your messages will be sent directly to OpenCode.
|
|
299
|
+
|
|
300
|
+
You: Add a dark mode toggle to settings
|
|
301
|
+
Bot: π Prompt: Add a dark mode toggle to settings
|
|
302
|
+
[streaming response...]
|
|
303
|
+
|
|
304
|
+
You: Now add a keyboard shortcut for it
|
|
305
|
+
Bot: π Prompt: Now add a keyboard shortcut for it
|
|
306
|
+
[streaming response...]
|
|
307
|
+
|
|
308
|
+
You: /code
|
|
309
|
+
Bot: β Passthrough mode disabled.
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Features:**
|
|
313
|
+
- π± **Mobile-friendly** β no more typing slash commands on phone
|
|
314
|
+
- π§΅ **Thread-scoped** β only affects the specific thread, not the whole channel
|
|
315
|
+
- β³ **Busy indicator** β shows β³ reaction if previous task is still running
|
|
316
|
+
- π **Safe** β ignores bot messages (no infinite loops)
|
|
317
|
+
|
|
318
|
+
### `/autowork` β Toggle Automatic Worktree Creation
|
|
319
|
+
|
|
320
|
+
Enable automatic worktree creation for a project. When enabled, new `/opencode` sessions will automatically create isolated git worktrees.
|
|
321
|
+
|
|
322
|
+
```
|
|
323
|
+
/autowork
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**How it works:**
|
|
327
|
+
1. Run `/autowork` in a channel bound to a project
|
|
328
|
+
2. The setting toggles on/off for that project
|
|
329
|
+
3. When enabled, new sessions automatically create worktrees with branch names like `auto/abc12345-1738600000000`
|
|
330
|
+
|
|
331
|
+
**Example:**
|
|
332
|
+
```
|
|
333
|
+
You: /autowork
|
|
334
|
+
Bot: β
Auto-worktree enabled for project myapp.
|
|
335
|
+
New sessions will automatically create isolated worktrees.
|
|
336
|
+
|
|
337
|
+
You: /opencode prompt:Add user authentication
|
|
338
|
+
Bot: [Creates thread + auto-worktree]
|
|
339
|
+
π³ Auto-Worktree: auto/abc12345-1738600000000
|
|
340
|
+
[Delete] [Create PR]
|
|
341
|
+
π Prompt: Add user authentication
|
|
342
|
+
[streaming response...]
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**Features:**
|
|
346
|
+
- π³ **Automatic isolation** β each session gets its own branch and worktree
|
|
347
|
+
- π± **Mobile-friendly** β no need to type `/work` with branch names
|
|
348
|
+
- ποΈ **Delete button** β removes worktree when done
|
|
349
|
+
- π **Create PR button** β easily create pull requests from worktree
|
|
350
|
+
- β‘ **Per-project setting** β enable/disable independently for each project
|
|
351
|
+
|
|
309
352
|
---
|
|
310
353
|
|
|
311
354
|
## Usage Workflow
|
|
@@ -342,6 +385,8 @@ Perfect for when you're away from your desk:
|
|
|
342
385
|
4. Watch real-time progress
|
|
343
386
|
5. Use the **Interrupt** button if needed
|
|
344
387
|
|
|
388
|
+
**Pro tip:** Enable passthrough mode with `/code` in a thread for an even smoother mobile experience β just type messages directly without slash commands!
|
|
389
|
+
|
|
345
390
|
### Team Collaboration Workflow
|
|
346
391
|
|
|
347
392
|
Share AI coding sessions with your team:
|
|
@@ -396,17 +441,21 @@ All configuration is stored in `~/.remote-opencode/`:
|
|
|
396
441
|
|
|
397
442
|
```json
|
|
398
443
|
{
|
|
399
|
-
"projects":
|
|
400
|
-
"myapp": "/Users/you/projects/my-app"
|
|
401
|
-
|
|
402
|
-
"bindings":
|
|
403
|
-
"channel-id": "myapp"
|
|
404
|
-
|
|
405
|
-
"threadSessions":
|
|
406
|
-
"worktreeMappings":
|
|
444
|
+
"projects": [
|
|
445
|
+
{ "alias": "myapp", "path": "/Users/you/projects/my-app", "autoWorktree": true }
|
|
446
|
+
],
|
|
447
|
+
"bindings": [
|
|
448
|
+
{ "channelId": "channel-id", "projectAlias": "myapp" }
|
|
449
|
+
],
|
|
450
|
+
"threadSessions": [ ... ],
|
|
451
|
+
"worktreeMappings": [ ... ]
|
|
407
452
|
}
|
|
408
453
|
```
|
|
409
454
|
|
|
455
|
+
| Field | Description |
|
|
456
|
+
|-------|-------------|
|
|
457
|
+
| `projects[].autoWorktree` | Optional. When `true`, new sessions auto-create worktrees |
|
|
458
|
+
|
|
410
459
|
---
|
|
411
460
|
|
|
412
461
|
## Troubleshooting
|
|
@@ -477,7 +526,7 @@ The bot maintains persistent sessions. If you encounter issues:
|
|
|
477
526
|
### Run from source
|
|
478
527
|
|
|
479
528
|
```bash
|
|
480
|
-
git clone https://github.com
|
|
529
|
+
git clone https://github.com/RoundTable02/remote-opencode.git
|
|
481
530
|
cd remote-opencode
|
|
482
531
|
npm install
|
|
483
532
|
|
|
@@ -504,13 +553,15 @@ src/
|
|
|
504
553
|
βββ bot.ts # Discord client initialization
|
|
505
554
|
βββ commands/ # Slash command definitions
|
|
506
555
|
β βββ opencode.ts # Main AI interaction command
|
|
556
|
+
β βββ code.ts # Passthrough mode toggle
|
|
507
557
|
β βββ work.ts # Worktree management
|
|
508
558
|
β βββ setpath.ts # Project registration
|
|
509
559
|
β βββ projects.ts # List projects
|
|
510
560
|
β βββ use.ts # Channel binding
|
|
511
561
|
βββ handlers/ # Interaction handlers
|
|
512
562
|
β βββ interactionHandler.ts
|
|
513
|
-
β
|
|
563
|
+
β βββ buttonHandler.ts
|
|
564
|
+
β βββ messageHandler.ts # Passthrough message handling
|
|
514
565
|
βββ services/ # Core business logic
|
|
515
566
|
β βββ serveManager.ts # OpenCode process management
|
|
516
567
|
β βββ sessionManager.ts # Session state management
|
package/dist/src/bot.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Client, GatewayIntentBits, Events } from 'discord.js';
|
|
|
2
2
|
import pc from 'picocolors';
|
|
3
3
|
import { getBotConfig } from './services/configStore.js';
|
|
4
4
|
import { handleInteraction } from './handlers/interactionHandler.js';
|
|
5
|
+
import { handleMessageCreate } from './handlers/messageHandler.js';
|
|
5
6
|
import * as serveManager from './services/serveManager.js';
|
|
6
7
|
export async function startBot() {
|
|
7
8
|
const config = getBotConfig();
|
|
@@ -9,12 +10,17 @@ export async function startBot() {
|
|
|
9
10
|
throw new Error('Bot configuration not found. Run setup first.');
|
|
10
11
|
}
|
|
11
12
|
const client = new Client({
|
|
12
|
-
intents: [
|
|
13
|
+
intents: [
|
|
14
|
+
GatewayIntentBits.Guilds,
|
|
15
|
+
GatewayIntentBits.GuildMessages,
|
|
16
|
+
GatewayIntentBits.MessageContent
|
|
17
|
+
]
|
|
13
18
|
});
|
|
14
19
|
client.once(Events.ClientReady, (c) => {
|
|
15
20
|
console.log(pc.green(`Ready! Logged in as ${pc.bold(c.user.tag)}`));
|
|
16
21
|
});
|
|
17
22
|
client.on(Events.InteractionCreate, handleInteraction);
|
|
23
|
+
client.on(Events.MessageCreate, handleMessageCreate);
|
|
18
24
|
function gracefulShutdown(signal) {
|
|
19
25
|
console.log(pc.yellow(`\n${signal} received. Shutting down gracefully...`));
|
|
20
26
|
serveManager.stopAll();
|
package/dist/src/cli.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import pc from 'picocolors';
|
|
4
|
+
import { createRequire } from 'module';
|
|
4
5
|
import { runSetupWizard } from './setup/wizard.js';
|
|
5
6
|
import { deployCommands } from './setup/deploy.js';
|
|
6
7
|
import { startBot } from './bot.js';
|
|
7
8
|
import { hasBotConfig, getConfigDir } from './services/configStore.js';
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const pkg = require('../../package.json');
|
|
8
11
|
const program = new Command();
|
|
9
12
|
program
|
|
10
13
|
.name('remote-opencode')
|
|
11
14
|
.description('Discord bot for remote OpenCode CLI access')
|
|
12
|
-
.version(
|
|
15
|
+
.version(pkg.version);
|
|
13
16
|
program
|
|
14
17
|
.command('start')
|
|
15
18
|
.description('Start the Discord bot')
|
|
@@ -19,6 +22,12 @@ program
|
|
|
19
22
|
console.log(`Run ${pc.cyan('remote-opencode setup')} first to configure your Discord bot.\n`);
|
|
20
23
|
process.exit(1);
|
|
21
24
|
}
|
|
25
|
+
try {
|
|
26
|
+
await deployCommands();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
console.log(pc.dim('Command deployment skipped (will retry on next start)'));
|
|
30
|
+
}
|
|
22
31
|
await startBot();
|
|
23
32
|
});
|
|
24
33
|
program
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
|
|
2
|
+
import * as dataStore from '../services/dataStore.js';
|
|
3
|
+
function getParentChannelId(interaction) {
|
|
4
|
+
const channel = interaction.channel;
|
|
5
|
+
if (channel?.isThread()) {
|
|
6
|
+
return channel.parentId ?? interaction.channelId;
|
|
7
|
+
}
|
|
8
|
+
return interaction.channelId;
|
|
9
|
+
}
|
|
10
|
+
export const autowork = {
|
|
11
|
+
data: new SlashCommandBuilder()
|
|
12
|
+
.setName('autowork')
|
|
13
|
+
.setDescription('Toggle automatic worktree creation for this channel\'s project'),
|
|
14
|
+
async execute(interaction) {
|
|
15
|
+
const channelId = getParentChannelId(interaction);
|
|
16
|
+
const projectAlias = dataStore.getChannelBinding(channelId);
|
|
17
|
+
if (!projectAlias) {
|
|
18
|
+
await interaction.reply({
|
|
19
|
+
content: 'β No project set for this channel. Use `/use <alias>` to bind a project first.',
|
|
20
|
+
flags: MessageFlags.Ephemeral
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const currentState = dataStore.getProjectAutoWorktree(projectAlias);
|
|
25
|
+
const newState = !currentState;
|
|
26
|
+
const success = dataStore.setProjectAutoWorktree(projectAlias, newState);
|
|
27
|
+
if (!success) {
|
|
28
|
+
await interaction.reply({
|
|
29
|
+
content: `β Project "${projectAlias}" not found.`,
|
|
30
|
+
flags: MessageFlags.Ephemeral
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const emoji = newState ? 'β
' : 'β';
|
|
35
|
+
const status = newState ? 'enabled' : 'disabled';
|
|
36
|
+
await interaction.reply({
|
|
37
|
+
content: `${emoji} Auto-worktree **${status}** for project **${projectAlias}**.\n\nNew sessions will ${newState ? 'automatically create' : 'NOT create'} isolated worktrees.`,
|
|
38
|
+
flags: MessageFlags.Ephemeral
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
|
|
2
|
+
import * as dataStore from '../services/dataStore.js';
|
|
3
|
+
export const code = {
|
|
4
|
+
data: new SlashCommandBuilder()
|
|
5
|
+
.setName('code')
|
|
6
|
+
.setDescription('Toggle passthrough mode - send messages directly to OpenCode'),
|
|
7
|
+
async execute(interaction) {
|
|
8
|
+
const channel = interaction.channel;
|
|
9
|
+
if (!channel?.isThread()) {
|
|
10
|
+
await interaction.reply({
|
|
11
|
+
content: 'β This command only works inside threads.',
|
|
12
|
+
flags: MessageFlags.Ephemeral
|
|
13
|
+
});
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const threadId = channel.id;
|
|
17
|
+
const parentChannelId = channel.parentId;
|
|
18
|
+
if (!parentChannelId) {
|
|
19
|
+
await interaction.reply({
|
|
20
|
+
content: 'β Cannot determine parent channel.',
|
|
21
|
+
flags: MessageFlags.Ephemeral
|
|
22
|
+
});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const projectPath = dataStore.getChannelProjectPath(parentChannelId);
|
|
26
|
+
if (!projectPath) {
|
|
27
|
+
await interaction.reply({
|
|
28
|
+
content: 'β No project set for this channel. Use `/use <alias>` in the parent channel first.',
|
|
29
|
+
flags: MessageFlags.Ephemeral
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const currentState = dataStore.isPassthroughEnabled(threadId);
|
|
34
|
+
const newState = !currentState;
|
|
35
|
+
dataStore.setPassthroughMode(threadId, newState, interaction.user.id);
|
|
36
|
+
if (newState) {
|
|
37
|
+
await interaction.reply({
|
|
38
|
+
content: 'β
**Passthrough mode enabled** for this thread.\nYour messages will be sent directly to OpenCode.',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
await interaction.reply({
|
|
43
|
+
content: 'β **Passthrough mode disabled.**\nUse `/opencode prompt:"..."` to send commands.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
@@ -4,9 +4,13 @@ import { projects } from './projects.js';
|
|
|
4
4
|
import { use } from './use.js';
|
|
5
5
|
import { opencode } from './opencode.js';
|
|
6
6
|
import { work } from './work.js';
|
|
7
|
+
import { code } from './code.js';
|
|
8
|
+
import { autowork } from './autowork.js';
|
|
7
9
|
export const commands = new Collection();
|
|
8
10
|
commands.set(setpath.data.name, setpath);
|
|
9
11
|
commands.set(projects.data.name, projects);
|
|
10
12
|
commands.set(use.data.name, use);
|
|
11
13
|
commands.set(opencode.data.name, opencode);
|
|
12
14
|
commands.set(work.data.name, work);
|
|
15
|
+
commands.set(code.data.name, code);
|
|
16
|
+
commands.set(autowork.data.name, autowork);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js';
|
|
1
|
+
import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, EmbedBuilder } from 'discord.js';
|
|
2
2
|
import * as dataStore from '../services/dataStore.js';
|
|
3
3
|
import * as sessionManager from '../services/sessionManager.js';
|
|
4
4
|
import * as serveManager from '../services/serveManager.js';
|
|
5
|
+
import * as worktreeManager from '../services/worktreeManager.js';
|
|
5
6
|
import { SSEClient } from '../services/sseClient.js';
|
|
6
7
|
import { getOrCreateThread } from '../utils/threadHelper.js';
|
|
7
8
|
import { formatOutput } from '../utils/messageFormatter.js';
|
|
@@ -48,7 +49,45 @@ export const opencode = {
|
|
|
48
49
|
return;
|
|
49
50
|
}
|
|
50
51
|
const threadId = thread.id;
|
|
51
|
-
|
|
52
|
+
// Auto-create worktree if enabled and this is a new thread (not isInThread)
|
|
53
|
+
let worktreeMapping = dataStore.getWorktreeMapping(threadId);
|
|
54
|
+
if (!worktreeMapping && !isInThread) {
|
|
55
|
+
const projectAlias = dataStore.getChannelBinding(channelId);
|
|
56
|
+
if (projectAlias && dataStore.getProjectAutoWorktree(projectAlias)) {
|
|
57
|
+
try {
|
|
58
|
+
const branchName = worktreeManager.sanitizeBranchName(`auto/${threadId.slice(0, 8)}-${Date.now()}`);
|
|
59
|
+
const worktreePath = await worktreeManager.createWorktree(projectPath, branchName);
|
|
60
|
+
const newMapping = {
|
|
61
|
+
threadId,
|
|
62
|
+
branchName,
|
|
63
|
+
worktreePath,
|
|
64
|
+
projectPath,
|
|
65
|
+
description: prompt.slice(0, 50) + (prompt.length > 50 ? '...' : ''),
|
|
66
|
+
createdAt: Date.now()
|
|
67
|
+
};
|
|
68
|
+
dataStore.setWorktreeMapping(newMapping);
|
|
69
|
+
worktreeMapping = newMapping;
|
|
70
|
+
const embed = new EmbedBuilder()
|
|
71
|
+
.setTitle(`π³ Auto-Worktree: ${branchName}`)
|
|
72
|
+
.setDescription('Automatically created for this session')
|
|
73
|
+
.addFields({ name: 'Branch', value: branchName, inline: true }, { name: 'Path', value: worktreePath, inline: true })
|
|
74
|
+
.setColor(0x2ecc71);
|
|
75
|
+
const worktreeButtons = new ActionRowBuilder()
|
|
76
|
+
.addComponents(new ButtonBuilder()
|
|
77
|
+
.setCustomId(`delete_${threadId}`)
|
|
78
|
+
.setLabel('Delete')
|
|
79
|
+
.setStyle(ButtonStyle.Danger), new ButtonBuilder()
|
|
80
|
+
.setCustomId(`pr_${threadId}`)
|
|
81
|
+
.setLabel('Create PR')
|
|
82
|
+
.setStyle(ButtonStyle.Primary));
|
|
83
|
+
await thread.send({ embeds: [embed], components: [worktreeButtons] });
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
console.error('Auto-worktree creation failed:', error);
|
|
87
|
+
// Continue with main project path (graceful degradation)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
52
91
|
const effectivePath = worktreeMapping?.worktreePath ?? projectPath;
|
|
53
92
|
const existingClient = sessionManager.getSseClient(threadId);
|
|
54
93
|
if (existingClient && existingClient.isConnected()) {
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
|
|
2
|
+
import * as dataStore from '../services/dataStore.js';
|
|
3
|
+
import * as sessionManager from '../services/sessionManager.js';
|
|
4
|
+
import * as serveManager from '../services/serveManager.js';
|
|
5
|
+
import * as worktreeManager from '../services/worktreeManager.js';
|
|
6
|
+
import { SSEClient } from '../services/sseClient.js';
|
|
7
|
+
import { formatOutput } from '../utils/messageFormatter.js';
|
|
8
|
+
export async function handleMessageCreate(message) {
|
|
9
|
+
if (message.author.bot)
|
|
10
|
+
return;
|
|
11
|
+
if (message.system)
|
|
12
|
+
return;
|
|
13
|
+
const channel = message.channel;
|
|
14
|
+
if (!channel.isThread())
|
|
15
|
+
return;
|
|
16
|
+
const threadId = channel.id;
|
|
17
|
+
if (!dataStore.isPassthroughEnabled(threadId))
|
|
18
|
+
return;
|
|
19
|
+
const parentChannelId = channel.parentId;
|
|
20
|
+
if (!parentChannelId)
|
|
21
|
+
return;
|
|
22
|
+
const projectPath = dataStore.getChannelProjectPath(parentChannelId);
|
|
23
|
+
if (!projectPath) {
|
|
24
|
+
await message.reply('β No project bound to parent channel. Disable passthrough and use `/use` first.');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
let worktreeMapping = dataStore.getWorktreeMapping(threadId);
|
|
28
|
+
// Auto-create worktree if enabled and no mapping exists for this thread
|
|
29
|
+
if (!worktreeMapping) {
|
|
30
|
+
const projectAlias = dataStore.getChannelBinding(parentChannelId);
|
|
31
|
+
if (projectAlias && dataStore.getProjectAutoWorktree(projectAlias)) {
|
|
32
|
+
try {
|
|
33
|
+
const branchName = worktreeManager.sanitizeBranchName(`auto/${threadId.slice(0, 8)}-${Date.now()}`);
|
|
34
|
+
const worktreePath = await worktreeManager.createWorktree(projectPath, branchName);
|
|
35
|
+
const prompt = message.content.trim();
|
|
36
|
+
const newMapping = {
|
|
37
|
+
threadId,
|
|
38
|
+
branchName,
|
|
39
|
+
worktreePath,
|
|
40
|
+
projectPath,
|
|
41
|
+
description: prompt.slice(0, 50) + (prompt.length > 50 ? '...' : ''),
|
|
42
|
+
createdAt: Date.now()
|
|
43
|
+
};
|
|
44
|
+
dataStore.setWorktreeMapping(newMapping);
|
|
45
|
+
worktreeMapping = newMapping;
|
|
46
|
+
const embed = new EmbedBuilder()
|
|
47
|
+
.setTitle(`π³ Auto-Worktree: ${branchName}`)
|
|
48
|
+
.setDescription('Automatically created for this session')
|
|
49
|
+
.addFields({ name: 'Branch', value: branchName, inline: true }, { name: 'Path', value: worktreePath, inline: true })
|
|
50
|
+
.setColor(0x2ecc71);
|
|
51
|
+
const worktreeButtons = new ActionRowBuilder()
|
|
52
|
+
.addComponents(new ButtonBuilder()
|
|
53
|
+
.setCustomId(`delete_${threadId}`)
|
|
54
|
+
.setLabel('Delete')
|
|
55
|
+
.setStyle(ButtonStyle.Danger), new ButtonBuilder()
|
|
56
|
+
.setCustomId(`pr_${threadId}`)
|
|
57
|
+
.setLabel('Create PR')
|
|
58
|
+
.setStyle(ButtonStyle.Primary));
|
|
59
|
+
await channel.send({ embeds: [embed], components: [worktreeButtons] });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
console.error('Auto-worktree creation failed:', error);
|
|
63
|
+
// Continue with main project path (graceful degradation)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const effectivePath = worktreeMapping?.worktreePath ?? projectPath;
|
|
68
|
+
const existingClient = sessionManager.getSseClient(threadId);
|
|
69
|
+
if (existingClient && existingClient.isConnected()) {
|
|
70
|
+
await message.react('β³');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const prompt = message.content.trim();
|
|
74
|
+
if (!prompt)
|
|
75
|
+
return;
|
|
76
|
+
const buttons = new ActionRowBuilder()
|
|
77
|
+
.addComponents(new ButtonBuilder()
|
|
78
|
+
.setCustomId(`interrupt_${threadId}`)
|
|
79
|
+
.setLabel('βΈοΈ Interrupt')
|
|
80
|
+
.setStyle(ButtonStyle.Secondary));
|
|
81
|
+
let streamMessage;
|
|
82
|
+
try {
|
|
83
|
+
streamMessage = await channel.send({
|
|
84
|
+
content: `π **Prompt**: ${prompt}\n\nπ Starting OpenCode server...`,
|
|
85
|
+
components: [buttons]
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let port;
|
|
92
|
+
let sessionId;
|
|
93
|
+
let updateInterval = null;
|
|
94
|
+
let accumulatedText = '';
|
|
95
|
+
let lastContent = '';
|
|
96
|
+
let tick = 0;
|
|
97
|
+
const spinner = ['β ', 'β ', 'β Ή', 'β Έ', 'β Ό', 'β ΄', 'β ¦', 'β §', 'β ', 'β '];
|
|
98
|
+
const updateStreamMessage = async (content, components) => {
|
|
99
|
+
try {
|
|
100
|
+
await streamMessage.edit({ content, components });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
port = await serveManager.spawnServe(effectivePath);
|
|
107
|
+
await updateStreamMessage(`π **Prompt**: ${prompt}\n\nβ³ Waiting for OpenCode server...`, [buttons]);
|
|
108
|
+
await serveManager.waitForReady(port);
|
|
109
|
+
const existingSession = sessionManager.getSessionForThread(threadId);
|
|
110
|
+
if (existingSession && existingSession.projectPath === effectivePath) {
|
|
111
|
+
const isValid = await sessionManager.validateSession(port, existingSession.sessionId);
|
|
112
|
+
if (isValid) {
|
|
113
|
+
sessionId = existingSession.sessionId;
|
|
114
|
+
sessionManager.updateSessionLastUsed(threadId);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
sessionId = await sessionManager.createSession(port);
|
|
118
|
+
sessionManager.setSessionForThread(threadId, sessionId, effectivePath, port);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
sessionId = await sessionManager.createSession(port);
|
|
123
|
+
sessionManager.setSessionForThread(threadId, sessionId, effectivePath, port);
|
|
124
|
+
}
|
|
125
|
+
const sseClient = new SSEClient();
|
|
126
|
+
sseClient.connect(`http://localhost:${port}`);
|
|
127
|
+
sessionManager.setSseClient(threadId, sseClient);
|
|
128
|
+
sseClient.onPartUpdated((part) => {
|
|
129
|
+
accumulatedText = part.text;
|
|
130
|
+
});
|
|
131
|
+
sseClient.onSessionIdle(() => {
|
|
132
|
+
if (updateInterval) {
|
|
133
|
+
clearInterval(updateInterval);
|
|
134
|
+
updateInterval = null;
|
|
135
|
+
}
|
|
136
|
+
(async () => {
|
|
137
|
+
try {
|
|
138
|
+
const formatted = formatOutput(accumulatedText);
|
|
139
|
+
const disabledButtons = new ActionRowBuilder()
|
|
140
|
+
.addComponents(new ButtonBuilder()
|
|
141
|
+
.setCustomId(`interrupt_${threadId}`)
|
|
142
|
+
.setLabel('βΈοΈ Interrupt')
|
|
143
|
+
.setStyle(ButtonStyle.Secondary)
|
|
144
|
+
.setDisabled(true));
|
|
145
|
+
await updateStreamMessage(`π **Prompt**: ${prompt}\n\n\`\`\`\n${formatted}\n\`\`\``, [disabledButtons]);
|
|
146
|
+
await channel.send({ content: 'β
Done' });
|
|
147
|
+
sseClient.disconnect();
|
|
148
|
+
sessionManager.clearSseClient(threadId);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
153
|
+
});
|
|
154
|
+
sseClient.onError((error) => {
|
|
155
|
+
if (updateInterval) {
|
|
156
|
+
clearInterval(updateInterval);
|
|
157
|
+
updateInterval = null;
|
|
158
|
+
}
|
|
159
|
+
(async () => {
|
|
160
|
+
try {
|
|
161
|
+
await updateStreamMessage(`π **Prompt**: ${prompt}\n\nβ Connection error: ${error.message}`, []);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
}
|
|
165
|
+
})();
|
|
166
|
+
});
|
|
167
|
+
updateInterval = setInterval(async () => {
|
|
168
|
+
tick++;
|
|
169
|
+
try {
|
|
170
|
+
const formatted = formatOutput(accumulatedText);
|
|
171
|
+
const spinnerChar = spinner[tick % spinner.length];
|
|
172
|
+
const newContent = formatted || 'Processing...';
|
|
173
|
+
if (newContent !== lastContent || tick % 2 === 0) {
|
|
174
|
+
lastContent = newContent;
|
|
175
|
+
await updateStreamMessage(`π **Prompt**: ${prompt}\n\n${spinnerChar} **Running...**\n\`\`\`\n${newContent}\n\`\`\``, [buttons]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
}
|
|
180
|
+
}, 1000);
|
|
181
|
+
await updateStreamMessage(`π **Prompt**: ${prompt}\n\nπ Sending prompt...`, [buttons]);
|
|
182
|
+
await sessionManager.sendPrompt(port, sessionId, prompt);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (updateInterval) {
|
|
186
|
+
clearInterval(updateInterval);
|
|
187
|
+
}
|
|
188
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
189
|
+
await updateStreamMessage(`π **Prompt**: ${prompt}\n\nβ OpenCode execution failed: ${errorMessage}`, []);
|
|
190
|
+
const client = sessionManager.getSseClient(threadId);
|
|
191
|
+
if (client) {
|
|
192
|
+
client.disconnect();
|
|
193
|
+
sessionManager.clearSseClient(threadId);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -150,3 +150,59 @@ export function getWorktreeMappingsByProject(projectPath) {
|
|
|
150
150
|
const data = loadData();
|
|
151
151
|
return data.worktreeMappings?.filter(m => m.projectPath === projectPath) ?? [];
|
|
152
152
|
}
|
|
153
|
+
export function setPassthroughMode(threadId, enabled, userId) {
|
|
154
|
+
const data = loadData();
|
|
155
|
+
if (!data.passthroughThreads) {
|
|
156
|
+
data.passthroughThreads = [];
|
|
157
|
+
}
|
|
158
|
+
const existing = data.passthroughThreads.findIndex(p => p.threadId === threadId);
|
|
159
|
+
if (existing >= 0) {
|
|
160
|
+
data.passthroughThreads[existing] = {
|
|
161
|
+
threadId,
|
|
162
|
+
enabled,
|
|
163
|
+
enabledBy: userId,
|
|
164
|
+
enabledAt: Date.now()
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
data.passthroughThreads.push({
|
|
169
|
+
threadId,
|
|
170
|
+
enabled,
|
|
171
|
+
enabledBy: userId,
|
|
172
|
+
enabledAt: Date.now()
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
saveData(data);
|
|
176
|
+
}
|
|
177
|
+
export function getPassthroughMode(threadId) {
|
|
178
|
+
const data = loadData();
|
|
179
|
+
return data.passthroughThreads?.find(p => p.threadId === threadId);
|
|
180
|
+
}
|
|
181
|
+
export function isPassthroughEnabled(threadId) {
|
|
182
|
+
const passthrough = getPassthroughMode(threadId);
|
|
183
|
+
return passthrough?.enabled ?? false;
|
|
184
|
+
}
|
|
185
|
+
export function removePassthroughMode(threadId) {
|
|
186
|
+
const data = loadData();
|
|
187
|
+
if (!data.passthroughThreads)
|
|
188
|
+
return false;
|
|
189
|
+
const idx = data.passthroughThreads.findIndex(p => p.threadId === threadId);
|
|
190
|
+
if (idx < 0)
|
|
191
|
+
return false;
|
|
192
|
+
data.passthroughThreads.splice(idx, 1);
|
|
193
|
+
saveData(data);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
export function setProjectAutoWorktree(alias, enabled) {
|
|
197
|
+
const data = loadData();
|
|
198
|
+
const project = data.projects.find(p => p.alias === alias);
|
|
199
|
+
if (!project)
|
|
200
|
+
return false;
|
|
201
|
+
project.autoWorktree = enabled;
|
|
202
|
+
saveData(data);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
export function getProjectAutoWorktree(alias) {
|
|
206
|
+
const project = getProject(alias);
|
|
207
|
+
return project?.autoWorktree ?? false;
|
|
208
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remote-opencode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Discord bot for remote OpenCode CLI access",
|
|
5
5
|
"main": "dist/src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
"dev": "node --loader ts-node/esm src/cli.ts",
|
|
20
20
|
"deploy-commands": "npm run build && node dist/src/cli.js deploy",
|
|
21
21
|
"test": "vitest",
|
|
22
|
-
"prepublishOnly": "npm run build"
|
|
22
|
+
"prepublishOnly": "npm run build",
|
|
23
|
+
"release": "npm version patch && npm run build && npm publish",
|
|
24
|
+
"release:minor": "npm version minor && npm run build && npm publish",
|
|
25
|
+
"release:major": "npm version major && npm run build && npm publish"
|
|
23
26
|
},
|
|
24
27
|
"keywords": [
|
|
25
28
|
"discord",
|