diff --git a/.env.example b/.env.example index 7ca672b..f164376 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,7 @@ SLACK_APP_TOKEN=xapp-... # CLAUDE_CONFIG_DIR=~/.claude # must contain the bridge plugin (see docs/slack.md) # ── Optional (have sensible defaults) ── +# HYDRA_MODEL=claude-opus-4-6[1m] # default model for byte + spawns; per-spawn override: hydra spawn --model # HYDRA_STATE_DIR=~/.claude/channels/discord # state dir (socket, sessions, access) # DEFAULT_SESSION_CHANNEL= # Discord: channel for spawned threads from DMs # TMUX_SESSION=discord-daemon # tmux session name for the daemon diff --git a/cli/hydra.ts b/cli/hydra.ts index f9904d3..b20616d 100644 --- a/cli/hydra.ts +++ b/cli/hydra.ts @@ -45,6 +45,7 @@ Spawn options (required): --idempotency-key Prevent duplicate spawns --channel Target channel for the spawned thread --message Create thread on this message (requires --channel) + --model Model for this session (default: $HYDRA_MODEL or claude-opus-4-6[1m]) Global options: --daemon Target a specific daemon @@ -116,6 +117,7 @@ async function main(): Promise { let message: string | undefined let quiet = false let ephemeral = false + let model: string | undefined const promptParts: string[] = [] for (let i = 1; i < filtered.length; i++) { @@ -131,6 +133,8 @@ async function main(): Promise { quiet = true } else if (filtered[i] === '--ephemeral') { ephemeral = true + } else if (filtered[i] === '--model' && i + 1 < filtered.length) { + model = filtered[++i] } else { promptParts.push(filtered[i]) } @@ -158,7 +162,7 @@ async function main(): Promise { type: 'cli', command: 'spawn', id: randomUUID(), - params: { prompt, idempotencyKey, initiator, ...(channel && { channel }), ...(message && { message }), ...(quiet && { quiet }), ...(ephemeral && { ephemeral }) }, + params: { prompt, idempotencyKey, initiator, model, ...(channel && { channel }), ...(message && { message }), ...(quiet && { quiet }), ...(ephemeral && { ephemeral }) }, }) printResponse(response, json) break diff --git a/cli/lifecycle.ts b/cli/lifecycle.ts index 35dacb6..da14a40 100644 --- a/cli/lifecycle.ts +++ b/cli/lifecycle.ts @@ -9,6 +9,7 @@ import { compileCheck, killOrphanBytes, hasOrphanBytes, appendLog, shq, waitForSocket, buildDaemonEnvs, } from './helpers.js' +import { DEFAULT_MODEL } from '../shared/constants.js' // --------------------------------------------------------------------------- // Start byte (replaces start-byte.sh) @@ -65,13 +66,14 @@ export async function startByte(cfg: HydraConfig): Promise { } const byteCwd = process.env.BYTE_CWD ?? cfg.spawnCwd + const byteModel = process.env.HYDRA_MODEL?.trim() || DEFAULT_MODEL const inner = [ `cd ${shq(byteCwd)}`, `export DAEMON_SOCK=${shq(cfg.sockPath)}`, `export CLAUDE_CONFIG_DIR=${shq(cfg.configDir)}`, `export CHAT_PLATFORM=${cfg.platform}`, authExport || null, - `caffeinate -i claude --model 'claude-opus-4-6[1m]' --channels plugin:discord@claude-plugins-official --dangerously-skip-permissions ${shq(prompt)}`, + `caffeinate -i claude --model ${shq(byteModel)} --channels plugin:discord@claude-plugins-official --dangerously-skip-permissions ${shq(prompt)}`, ].filter(Boolean).join(' && ') tmuxSpawn(cfg.byteTmux, inner) diff --git a/daemon/bridge-dispatch.ts b/daemon/bridge-dispatch.ts index 100b071..2ae10c7 100644 --- a/daemon/bridge-dispatch.ts +++ b/daemon/bridge-dispatch.ts @@ -7,6 +7,7 @@ import { doSpawnSession, killSession } from './session-lifecycle.js' import { fallbackDescription, formatDuration, getContextPercent, chunk, assertSendable, isAlive } from './util.js' import { watchPr, unwatchPr, listWatches, getWatchesBySession, formatWatchEntry, detectPrUrl, WATCH_ERRORS } from './pr-watch.js' import { refreshSessionVisual } from './anchor-state.js' +import { DEFAULT_MODEL } from '../shared/constants.js' const SEND_RETRY_ATTEMPTS = 3 const SEND_RETRY_BASE_MS = 1_000 @@ -43,7 +44,7 @@ export const BRIDGE_TOOLS = [ { name: 'download_attachment', description: 'Download attachments from a message.', inputSchema: { type: 'object', properties: { chat_id: { type: 'string' }, message_id: { type: 'string' } }, required: ['chat_id', 'message_id'] } }, { name: 'create_thread', description: 'Create a thread in a channel.', inputSchema: { type: 'object', properties: { chat_id: { type: 'string' }, message_id: { type: 'string' }, name: { type: 'string' }, text: { type: 'string' }, auto_archive_minutes: { type: 'number' }, files: { type: 'array', items: { type: 'string' } } }, required: ['chat_id', 'name'] } }, { name: 'fetch_messages', description: 'Fetch recent messages from a channel.', inputSchema: { type: 'object', properties: { channel: { type: 'string' }, limit: { type: 'number' } }, required: ['channel'] } }, - { name: 'spawn_session', description: 'Spawn a new Claude session. Main session only. Pass worktree with a repo directory name (e.g. "options_bot") to spawn in an isolated git worktree.', inputSchema: { type: 'object', properties: { topic: { type: 'string' }, chat_id: { type: 'string' }, message_id: { type: 'string' }, worktree: { type: 'string', description: 'Git repo subdirectory to create a worktree from (e.g. "options_bot", "anytester"). Session gets an isolated copy.' } }, required: ['topic'] } }, + { name: 'spawn_session', description: 'Spawn a new Claude session. Main session only. Pass worktree with a repo directory name (e.g. "options_bot") to spawn in an isolated git worktree.', inputSchema: { type: 'object', properties: { topic: { type: 'string' }, chat_id: { type: 'string' }, message_id: { type: 'string' }, worktree: { type: 'string', description: 'Git repo subdirectory to create a worktree from (e.g. "options_bot", "anytester"). Session gets an isolated copy.' }, model: { type: 'string', description: 'Model ID for this spawn (overrides HYDRA_MODEL).' } }, required: ['topic'] } }, { name: 'list_sessions', description: 'List all active sessions. Main session only.', inputSchema: { type: 'object', properties: {} } }, { name: 'kill_session', description: 'Kill a session by ID or thread ID. Main session only.', inputSchema: { type: 'object', properties: { session_id: { type: 'string' }, thread_id: { type: 'string' } } } }, { name: 'set_description', description: 'Set a brief description for your session.', inputSchema: { type: 'object', properties: { session_id: { type: 'string' }, description: { type: 'string' } }, required: ['session_id', 'description'] } }, @@ -52,7 +53,8 @@ export const BRIDGE_TOOLS = [ { name: 'list_watches', description: 'List all PRs being watched (your session or all).', inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'Show all watches, not just yours' } } } }, ] -export const SPAWN_MODEL = 'claude-opus-4-6[1m]' +// Frozen at import time — daemon restart required to pick up changes. +export const SPAWN_MODEL = process.env.HYDRA_MODEL?.trim() || DEFAULT_MODEL export const MAIN_ONLY_TOOLS = new Set(['spawn_session', 'list_sessions', 'kill_session']) export function computeToolsForSession(sessionId: string): typeof BRIDGE_TOOLS { @@ -203,7 +205,9 @@ export async function executeTool(name: string, args: Record, c case 'spawn_session': { const worktree = args.worktree as string | undefined const topic = worktree ? `worktree:${worktree} ${args.topic}` : args.topic as string - const result = await doSpawnSession(topic, args.chat_id as string | undefined, args.message_id as string | undefined) + const model = (args.model as string | undefined)?.trim() || undefined + if (model) process.stderr.write(`daemon: spawn_session model override: ${model}\n`) + const result = await doSpawnSession(topic, args.chat_id as string | undefined, args.message_id as string | undefined, model ? { model } : undefined) return { content: [{ type: 'text', text: `session spawned (name: ${result.name}, session_id: ${result.sessionId}, thread_id: ${result.threadId}${result.url ? `, url: ${result.url}` : ''})` }] } } diff --git a/daemon/cli-handler.ts b/daemon/cli-handler.ts index 8caec09..a82e28b 100644 --- a/daemon/cli-handler.ts +++ b/daemon/cli-handler.ts @@ -62,7 +62,7 @@ function respond(req: CLIRequest, ok: boolean, dataOrError?: unknown, maybeData? // --------------------------------------------------------------------------- async function handleSpawn(req: CLIRequest): Promise { - const { prompt, initiator, idempotencyKey, channel, message, ephemeral, quiet } = req.params as { + const { prompt, initiator, idempotencyKey, channel, message, ephemeral, quiet, model } = req.params as { prompt?: string initiator?: string idempotencyKey?: string @@ -70,6 +70,7 @@ async function handleSpawn(req: CLIRequest): Promise { message?: string ephemeral?: boolean quiet?: boolean + model?: string } if (!prompt) return respond(req, false, 'prompt is required') @@ -88,7 +89,7 @@ async function handleSpawn(req: CLIRequest): Promise { let result try { - result = await doSpawnSession(prompt, channel ?? undefined, message ?? undefined, { initiator, ephemeral }) + result = await doSpawnSession(prompt, channel ?? undefined, message ?? undefined, { initiator, model, ephemeral }) } catch (err) { updateIdempotency(idempotencyKey, { status: 'failed' }) throw err diff --git a/daemon/session-lifecycle.ts b/daemon/session-lifecycle.ts index 3543b04..6c32c59 100644 --- a/daemon/session-lifecycle.ts +++ b/daemon/session-lifecycle.ts @@ -370,6 +370,8 @@ export async function doSpawnSession(topic: string, chatId?: string, messageId?: prompt = buildSpawnPrompt(promptParams) } + const model = opts?.model ?? SPAWN_MODEL + // Build claude command — fork adds --resume --fork-session, resume uses --resume without fork let claudeArgs: string if (isFork) { @@ -377,7 +379,7 @@ export async function doSpawnSession(topic: string, chatId?: string, messageId?: `claude`, `--resume ${shq(opts!.forkFrom!.claudeSessionId)}`, `--fork-session`, - `--model ${shq(SPAWN_MODEL)}`, + `--model ${shq(model)}`, `--channels ${shq(channelFlag)}`, `--dangerously-skip-permissions`, shq(prompt), @@ -386,12 +388,12 @@ export async function doSpawnSession(topic: string, chatId?: string, messageId?: claudeArgs = [ `claude`, `--resume ${shq(opts!.resumeFrom!)}`, - `--model ${shq(SPAWN_MODEL)}`, + `--model ${shq(model)}`, `--channels ${shq(channelFlag)}`, `--dangerously-skip-permissions`, ].join(' ') } else { - claudeArgs = `claude --model ${shq(SPAWN_MODEL)} --channels ${shq(channelFlag)} --dangerously-skip-permissions ${shq(prompt)}` + claudeArgs = `claude --model ${shq(model)} --channels ${shq(channelFlag)} --dangerously-skip-permissions ${shq(prompt)}` } const inner = [ @@ -427,7 +429,7 @@ export async function doSpawnSession(topic: string, chatId?: string, messageId?: const capabilities: SessionCapabilities = { role: 'worker', tools: computeToolsForSession(sessionId).map(t => t.name), - model: SPAWN_MODEL, + model, cwd: effectiveCwd, platform: PLATFORM, } diff --git a/daemon/sessions.ts b/daemon/sessions.ts index a518937..bbdfb4b 100644 --- a/daemon/sessions.ts +++ b/daemon/sessions.ts @@ -98,6 +98,7 @@ export type SpawnOpts = { memberLabel?: string // label for thread member (e.g. 'critic', 'judge') initiator?: string ephemeral?: boolean // auto-kill on [done] sentinel, skip death visuals + model?: string // per-spawn model override (falls back to SPAWN_MODEL / HYDRA_MODEL) } // --------------------------------------------------------------------------- diff --git a/shared/constants.ts b/shared/constants.ts new file mode 100644 index 0000000..0f1e3b9 --- /dev/null +++ b/shared/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_MODEL = 'claude-opus-4-6[1m]' diff --git a/tsconfig.json b/tsconfig.json index 829282c..37ae797 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,6 @@ "esModuleInterop": true, "resolveJsonModule": true }, - "include": ["*.ts", "daemon/**/*.ts"], + "include": ["*.ts", "daemon/**/*.ts", "cli/**/*.ts", "shared/**/*.ts"], "exclude": ["node_modules", "slack-gateway.ts"] }