Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,26 @@ Enable automatic worktree creation for a project. When enabled, new `/opencode`
- 🚀 **Create PR button** — easily create pull requests from worktree
- ⚡ **Per-project setting** — enable/disable independently for each project

### `/autocode` — Toggle Automatic Passthrough Mode

Enable automatic passthrough mode for a project. When enabled, every new thread the bot creates (via `/work` or `/opencode`) will already have passthrough mode on, so plain messages are sent to OpenCode without needing to run `/code` first.

```
/autocode
```

**How it works:**

1. Run `/autocode` in a channel bound to a project
2. The setting toggles on/off for that project
3. New threads in that project skip the manual `/code` step

**Features:**

- 📱 **Mobile-friendly** — one less command to type per thread
- 🧵 **Thread-scoped** — each new thread starts with passthrough enabled; `/code` still toggles it within a thread
- ⚡ **Per-project setting** — enable/disable independently for each project

### `/queue` — Manage Message Queue

Control the automated job queue for the current thread.
Expand Down
176 changes: 176 additions & 0 deletions src/__tests__/autocode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ChannelType } from 'discord.js';

vi.mock('../services/dataStore.js');
vi.mock('../services/worktreeManager.js');

import { autocode } from '../commands/autocode.js';
import { work } from '../commands/work.js';
import { getOrCreateThread } from '../utils/threadHelper.js';
import * as dataStore from '../services/dataStore.js';
import * as worktreeManager from '../services/worktreeManager.js';

function makeInteraction(channelId: string, isThread = false, parentId?: string) {
return {
channelId,
channel: isThread
? { isThread: () => true, parentId }
: { isThread: () => false },
reply: vi.fn().mockResolvedValue(undefined),
} as any;
}

describe('/autocode', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('replies with an error when no project is bound to the channel', async () => {
const interaction = makeInteraction('channel-1');
vi.mocked(dataStore.getChannelBinding).mockReturnValue(undefined);

await autocode.execute(interaction);

expect(interaction.reply).toHaveBeenCalledOnce();
const arg = interaction.reply.mock.calls[0][0];
expect(arg.content).toContain('No project set');
expect(dataStore.setProjectAutoPassthrough).not.toHaveBeenCalled();
});

it('flips false → true and reports enabled', async () => {
const interaction = makeInteraction('channel-1');
vi.mocked(dataStore.getChannelBinding).mockReturnValue('myproj');
vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(false);
vi.mocked(dataStore.setProjectAutoPassthrough).mockReturnValue(true);

await autocode.execute(interaction);

expect(dataStore.setProjectAutoPassthrough).toHaveBeenCalledWith('myproj', true);
const arg = interaction.reply.mock.calls[0][0];
expect(arg.content).toMatch(/enabled/);
expect(arg.content).toContain('myproj');
});

it('flips true → false and reports disabled', async () => {
const interaction = makeInteraction('channel-1');
vi.mocked(dataStore.getChannelBinding).mockReturnValue('myproj');
vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(true);
vi.mocked(dataStore.setProjectAutoPassthrough).mockReturnValue(true);

await autocode.execute(interaction);

expect(dataStore.setProjectAutoPassthrough).toHaveBeenCalledWith('myproj', false);
const arg = interaction.reply.mock.calls[0][0];
expect(arg.content).toMatch(/disabled/);
});

it('resolves to the parent channel binding when invoked from a thread', async () => {
const interaction = makeInteraction('thread-id', true, 'parent-channel');
vi.mocked(dataStore.getChannelBinding).mockImplementation((id: string) =>
id === 'parent-channel' ? 'myproj' : undefined
);
vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(false);
vi.mocked(dataStore.setProjectAutoPassthrough).mockReturnValue(true);

await autocode.execute(interaction);

expect(dataStore.getChannelBinding).toHaveBeenCalledWith('parent-channel');
expect(dataStore.setProjectAutoPassthrough).toHaveBeenCalledWith('myproj', true);
});

it('reports failure when setProjectAutoPassthrough returns false', async () => {
const interaction = makeInteraction('channel-1');
vi.mocked(dataStore.getChannelBinding).mockReturnValue('ghostproj');
vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(false);
vi.mocked(dataStore.setProjectAutoPassthrough).mockReturnValue(false);

await autocode.execute(interaction);

const arg = interaction.reply.mock.calls[0][0];
expect(arg.content).toContain('not found');
});
});

describe('auto-passthrough seeding via /work', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(worktreeManager.sanitizeBranchName).mockImplementation((b: string) => b);
vi.mocked(worktreeManager.createWorktree).mockResolvedValue('/tmp/worktree');

vi.mocked(dataStore.getChannelProjectPath).mockReturnValue('/tmp/project');
vi.mocked(dataStore.getWorktreeMappingByBranch).mockReturnValue(undefined);
vi.mocked(dataStore.setWorktreeMapping).mockReturnValue(undefined);
vi.mocked(dataStore.getChannelBinding).mockReturnValue('myproj');
});

async function runWork() {
const threadsCreate = vi.fn().mockResolvedValue({
id: 'new-thread-1',
send: vi.fn().mockResolvedValue(undefined),
});

const interaction = {
channelId: 'channel-1',
user: { id: 'user-1' },
channel: {
isThread: () => false,
type: ChannelType.GuildText,
threads: { create: threadsCreate },
},
options: { getString: (n: string) => (n === 'branch' ? 'feat-x' : null) },
reply: vi.fn().mockResolvedValue(undefined),
deferReply: vi.fn().mockResolvedValue(undefined),
editReply: vi.fn().mockResolvedValue(undefined),
} as any;

await work.execute(interaction);
}

it('seeds passthrough on the new thread when project has autoPassthrough enabled', async () => {
vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(true);
await runWork();
expect(dataStore.setPassthroughMode).toHaveBeenCalledWith('new-thread-1', true, 'user-1');
});

it('does not seed passthrough when autoPassthrough is disabled', async () => {
vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(false);
await runWork();
expect(dataStore.setPassthroughMode).not.toHaveBeenCalled();
});
});

describe('auto-passthrough seeding via /opencode (threadHelper)', () => {
beforeEach(() => {
vi.clearAllMocks();
});

async function runHelper() {
const threadsCreate = vi.fn().mockResolvedValue({ id: 'opencode-thread-1' });
const interaction = {
channelId: 'channel-1',
user: { id: 'user-1' },
channel: { isThread: () => false, threads: { create: threadsCreate } },
} as any;
return await getOrCreateThread(interaction, 'do a thing');
}

it('seeds passthrough when bound project has autoPassthrough enabled', async () => {
vi.mocked(dataStore.getChannelBinding).mockReturnValue('myproj');
vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(true);
await runHelper();
expect(dataStore.setPassthroughMode).toHaveBeenCalledWith('opencode-thread-1', true, 'user-1');
});

it('does not seed when no project is bound', async () => {
vi.mocked(dataStore.getChannelBinding).mockReturnValue(undefined);
await runHelper();
expect(dataStore.setPassthroughMode).not.toHaveBeenCalled();
});

it('does not seed when autoPassthrough is disabled', async () => {
vi.mocked(dataStore.getChannelBinding).mockReturnValue('myproj');
vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(false);
await runHelper();
expect(dataStore.setPassthroughMode).not.toHaveBeenCalled();
});
});
54 changes: 54 additions & 0 deletions src/commands/autocode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
SlashCommandBuilder,
ChatInputCommandInteraction,
MessageFlags,
ThreadChannel
} from 'discord.js';
import * as dataStore from '../services/dataStore.js';
import type { Command } from './index.js';

function getParentChannelId(interaction: ChatInputCommandInteraction): string {
const channel = interaction.channel;
if (channel?.isThread()) {
return (channel as ThreadChannel).parentId ?? interaction.channelId;
}
return interaction.channelId;
}

export const autocode: Command = {
data: new SlashCommandBuilder()
.setName('autocode')
.setDescription('Toggle automatic passthrough mode for new threads in this channel\'s project') as SlashCommandBuilder,

async execute(interaction: ChatInputCommandInteraction) {
const channelId = getParentChannelId(interaction);
const projectAlias = dataStore.getChannelBinding(channelId);

if (!projectAlias) {
await interaction.reply({
content: '❌ No project set for this channel. Use `/use <alias>` to bind a project first.',
flags: MessageFlags.Ephemeral
});
return;
}

const currentState = dataStore.getProjectAutoPassthrough(projectAlias);
const newState = !currentState;

const success = dataStore.setProjectAutoPassthrough(projectAlias, newState);
if (!success) {
await interaction.reply({
content: `❌ Project "${projectAlias}" not found.`,
flags: MessageFlags.Ephemeral
});
return;
}

const emoji = newState ? '✅' : '❌';
const status = newState ? 'enabled' : 'disabled';
await interaction.reply({
content: `${emoji} Auto-passthrough **${status}** for project **${projectAlias}**.\n\nNew threads will ${newState ? 'automatically enable' : 'NOT automatically enable'} passthrough mode (no slash command needed).`,
flags: MessageFlags.Ephemeral
});
}
};
2 changes: 2 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { opencode } from './opencode.js';
import { work } from './work.js';
import { code } from './code.js';
import { autowork } from './autowork.js';
import { autocode } from './autocode.js';
import { model } from './model.js';
import { setports } from './setports.js';
import { queue } from './queue.js';
Expand All @@ -28,6 +29,7 @@ commands.set(opencode.data.name, opencode);
commands.set(work.data.name, work);
commands.set(code.data.name, code);
commands.set(autowork.data.name, autowork);
commands.set(autocode.data.name, autocode);
commands.set(model.data.name, model);
commands.set(setports.data.name, setports as Command);
commands.set(queue.data.name, queue);
Expand Down
5 changes: 5 additions & 0 deletions src/commands/work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ export const work: Command = {
createdAt: Date.now()
});

const alias = dataStore.getChannelBinding(i.channelId);
if (alias && dataStore.getProjectAutoPassthrough(alias)) {
dataStore.setPassthroughMode(thread.id, true, i.user.id);
}

const embed = new EmbedBuilder()
.setTitle(`🌳 Worktree: ${sanitizedBranch}`)
.setDescription(description)
Expand Down
14 changes: 14 additions & 0 deletions src/services/dataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,20 @@ export function getProjectAutoWorktree(alias: string): boolean {
return project?.autoWorktree ?? false;
}

export function setProjectAutoPassthrough(alias: string, enabled: boolean): boolean {
const data = loadData();
const project = data.projects.find(p => p.alias === alias);
if (!project) return false;
project.autoPassthrough = enabled;
saveData(data);
return true;
}

export function getProjectAutoPassthrough(alias: string): boolean {
const project = getProject(alias);
return project?.autoPassthrough ?? false;
}

// Queue Management
export function getQueue(threadId: string): QueuedMessage[] {
const data = loadData();
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface ProjectConfig {
alias: string;
path: string;
autoWorktree?: boolean;
autoPassthrough?: boolean;
}

export interface ChannelBinding {
Expand Down
13 changes: 10 additions & 3 deletions src/utils/threadHelper.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
import { ChatInputCommandInteraction, ThreadAutoArchiveDuration, TextChannel, ThreadChannel } from 'discord.js';
import * as dataStore from '../services/dataStore.js';

export async function getOrCreateThread(
interaction: ChatInputCommandInteraction,
prompt: string
): Promise<ThreadChannel> {
const channel = interaction.channel;

if (channel?.isThread()) {
return channel;
}

if (channel && 'threads' in channel) {
const threadName = prompt.slice(0, 50) + (prompt.length > 50 ? '...' : '');
const thread = await (channel as TextChannel).threads.create({
name: `🤖 ${threadName}`,
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
reason: 'OpenCode session'
});

const alias = dataStore.getChannelBinding(interaction.channelId);
if (alias && dataStore.getProjectAutoPassthrough(alias)) {
dataStore.setPassthroughMode(thread.id, true, interaction.user.id);
}

return thread;
}

throw new Error('Cannot create thread in this channel.');
}