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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>
# 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
Expand Down
6 changes: 5 additions & 1 deletion cli/hydra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Spawn options (required):
--idempotency-key <key> Prevent duplicate spawns
--channel <id> Target channel for the spawned thread
--message <id> Create thread on this message (requires --channel)
--model <id> Model for this session (default: $HYDRA_MODEL or claude-opus-4-6[1m])

Global options:
--daemon <name> Target a specific daemon
Expand Down Expand Up @@ -116,6 +117,7 @@ async function main(): Promise<void> {
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++) {
Expand All @@ -131,6 +133,8 @@ async function main(): Promise<void> {
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])
}
Expand Down Expand Up @@ -158,7 +162,7 @@ async function main(): Promise<void> {
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
Expand Down
4 changes: 3 additions & 1 deletion cli/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -65,13 +66,14 @@ export async function startByte(cfg: HydraConfig): Promise<void> {
}

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)
Expand Down
10 changes: 7 additions & 3 deletions daemon/bridge-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'] } },
Expand All @@ -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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (flagged by both reviewers): This import is placed mid-file after the BRIDGE_TOOLS array and other exports. ESM hoisting makes it work at runtime, but it breaks the codebase convention of imports-at-top and may trip linters. Move it up with the other imports at the top of the file.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — moved to top with the other imports.

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 {
Expand Down Expand Up @@ -203,7 +205,9 @@ export async function executeTool(name: string, args: Record<string, unknown>, 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}` : ''})` }] }
}

Expand Down
5 changes: 3 additions & 2 deletions daemon/cli-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,15 @@ function respond(req: CLIRequest, ok: boolean, dataOrError?: unknown, maybeData?
// ---------------------------------------------------------------------------

async function handleSpawn(req: CLIRequest): Promise<CLIResponse> {
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
channel?: string
message?: string
ephemeral?: boolean
quiet?: boolean
model?: string
}

if (!prompt) return respond(req, false, 'prompt is required')
Expand All @@ -88,7 +89,7 @@ async function handleSpawn(req: CLIRequest): Promise<CLIResponse> {

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
Expand Down
10 changes: 6 additions & 4 deletions daemon/session-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,14 +370,16 @@ export async function doSpawnSession(topic: string, chatId?: string, messageId?:
prompt = buildSpawnPrompt(promptParams)
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix: empty-string model bypasses fallback. If --model "" is passed, the ?? operator doesn't catch it (empty string is not nullish), so shq('') produces '' and the spawned claude --model '' will fail opaquely. Use opts?.model || SPAWN_MODEL (falsy check), or validate at the CLI parsing layer in hydra.ts.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — switched to || (falsy check) so empty string falls through to SPAWN_MODEL. Also applied the same fix to the SPAWN_MODEL env-read itself in bridge-dispatch.ts:58 which had the same latent issue.

const model = opts?.model ?? SPAWN_MODEL

// Build claude command — fork adds --resume --fork-session, resume uses --resume without fork
let claudeArgs: string
if (isFork) {
claudeArgs = [
`claude`,
`--resume ${shq(opts!.forkFrom!.claudeSessionId)}`,
`--fork-session`,
`--model ${shq(SPAWN_MODEL)}`,
`--model ${shq(model)}`,
`--channels ${shq(channelFlag)}`,
`--dangerously-skip-permissions`,
shq(prompt),
Expand All @@ -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 = [
Expand Down Expand Up @@ -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,
}
Expand Down
1 change: 1 addition & 0 deletions daemon/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions shared/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DEFAULT_MODEL = 'claude-opus-4-6[1m]'

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (flagged by sp-reviewer): tsconfig.json include is ["*.ts", "daemon/**/*.ts"] — neither shared/ nor cli/ are covered. This new file (and cli/lifecycle.ts which imports it) won't be type-checked by tsc. Add "shared/**/*.ts" (and ideally "cli/**/*.ts") to include.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added cli/**/*.ts and shared/**/*.ts to tsconfig include.

2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}