Skip to content

Commit a3ce567

Browse files
alari76claude
andauthored
feat(joe): pick Agent Joe's model from the composer (#599)
Joe's session was created without a --model flag, so it ran on whatever the Claude CLI defaulted to — no way to move it without editing code. It now has the same model control a session has. The choice cannot live on the session: Joe's session is recreated on demand. It is stored as the archive setting `agent_model`, written by the set_model handler and read when the session is created or restarted. With no explicit choice Joe follows the latest known Claude model, so it stops being stranded on whatever was newest the day its session was created. A live process keeps its model until setModel() restarts it. On the client the model half of the agent control is no longer suppressed for the orchestrator variant; the harness half stays hidden, since Joe is Claude-only. That also forces activeSessionProvider to 'claude' in the orchestrator view — Joe's session is not in the sessions list, so the old fallback used the default new-session provider, and a Codex/OpenCode default would have pushed a foreign model onto Joe's Claude process. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 08bc4ed commit a3ce567

8 files changed

Lines changed: 170 additions & 4 deletions

docs/ORCHESTRATOR-SPEC.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@ Agent Joe is a `ClaudeProcess` session with:
110110
- `permissionMode: 'acceptEdits'` (it needs to read reports, write memory, spawn sessions)
111111
- Working directory: `~/.codekin/orchestrator/` (its own workspace)
112112

113+
**Model**: picked from the model control in Joe's composer, exactly as in a
114+
regular session. Because Joe's session is recreated on demand, the choice is
115+
stored outside it — archive setting `agent_model`, written by the `set_model`
116+
WebSocket handler and read by `ensureOrchestratorRunning`. With no explicit
117+
choice, Joe tracks the latest known Claude model (`getDefaultClaudeModel()`),
118+
so it is never stranded on whatever was newest the day its session was created.
119+
A model change restarts Joe's CLI process, like it does for a session.
120+
113121
Methods exposed by `SessionManager`:
114122
```typescript
115123
// In session-manager.ts

server/orchestrator-manager.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ vi.mock('./config.js', () => ({
2626
getAgentDisplayName: () => 'TestAgent',
2727
}))
2828

29+
vi.mock('./anthropic-models.js', () => ({
30+
getDefaultClaudeModel: () => 'claude-latest-test',
31+
}))
32+
2933
import {
3034
ORCHESTRATOR_DIR,
3135
ensureOrchestratorDir,
@@ -34,17 +38,23 @@ import {
3438
ensureOrchestratorRunning,
3539
ensureOrchestratorMcpConfig,
3640
getOrchestratorSessionId,
41+
getOrchestratorModel,
42+
setOrchestratorModel,
3743
readTemplateVersion,
3844
CLAUDE_MD_TEMPLATE_VERSION,
3945
ORCHESTRATOR_ALLOWED_TOOLS,
4046
} from './orchestrator-manager.js'
4147

42-
function fakeSessionManager(existingSession?: any) {
48+
function fakeSessionManager(existingSession?: any, settings: Record<string, string> = {}) {
4349
return {
4450
get: vi.fn((id: string) => existingSession && existingSession.id === id ? existingSession : undefined),
4551
create: vi.fn((_name: string, _dir: string, opts?: any) => ({ id: opts?.id ?? 'new-id', ...opts })),
4652
startClaude: vi.fn(),
4753
persistToDisk: vi.fn(),
54+
archive: {
55+
getSetting: vi.fn((key: string, fallback = '') => settings[key] ?? fallback),
56+
setSetting: vi.fn((key: string, value: string) => { settings[key] = value }),
57+
},
4858
} as any
4959
}
5060

@@ -213,11 +223,64 @@ describe('ensureOrchestratorRunning', () => {
213223
id: 'test-uuid-1234',
214224
permissionMode: 'acceptEdits',
215225
allowedTools: ORCHESTRATOR_ALLOWED_TOOLS,
226+
model: 'claude-latest-test',
216227
}),
217228
)
218229
expect(sm.startClaude).toHaveBeenCalledWith('test-uuid-1234')
219230
})
220231

232+
it('creates the session with the stored model when one was chosen', () => {
233+
mockExistsSync.mockReturnValue(false)
234+
235+
const sm = fakeSessionManager(undefined, { agent_model: 'claude-sonnet-5' })
236+
ensureOrchestratorRunning(sm)
237+
238+
expect(sm.create).toHaveBeenCalledWith(
239+
'Agent TestAgent',
240+
'/tmp/test-data/orchestrator',
241+
expect.objectContaining({ model: 'claude-sonnet-5' }),
242+
)
243+
})
244+
245+
it('adopts the stored model when restarting a stopped session', () => {
246+
mockExistsSync.mockImplementation((p: string) =>
247+
typeof p === 'string' && p.endsWith('.session-id') ? true : false,
248+
)
249+
mockReadFileSync.mockReturnValue('test-uuid-1234')
250+
251+
const session = {
252+
id: 'test-uuid-1234',
253+
model: 'claude-opus-4-7',
254+
allowedTools: ['Bash(curl:*)', 'CronCreate', 'CronDelete', 'CronList'],
255+
claudeProcess: { isAlive: () => false },
256+
}
257+
const sm = fakeSessionManager(session, { agent_model: 'claude-sonnet-5' })
258+
ensureOrchestratorRunning(sm)
259+
260+
expect(session.model).toBe('claude-sonnet-5')
261+
expect(sm.persistToDisk).toHaveBeenCalled()
262+
expect(sm.startClaude).toHaveBeenCalledWith('test-uuid-1234')
263+
})
264+
265+
it('leaves a live session on the model its process was started with', () => {
266+
mockExistsSync.mockImplementation((p: string) =>
267+
typeof p === 'string' && p.endsWith('.session-id') ? true : false,
268+
)
269+
mockReadFileSync.mockReturnValue('test-uuid-1234')
270+
271+
const session = {
272+
id: 'test-uuid-1234',
273+
model: 'claude-opus-4-7',
274+
allowedTools: ['Bash(curl:*)', 'CronCreate', 'CronDelete', 'CronList'],
275+
claudeProcess: { isAlive: () => true },
276+
}
277+
const sm = fakeSessionManager(session, { agent_model: 'claude-sonnet-5' })
278+
ensureOrchestratorRunning(sm)
279+
280+
expect(session.model).toBe('claude-opus-4-7')
281+
expect(sm.startClaude).not.toHaveBeenCalled()
282+
})
283+
221284
it('restarts Claude when session exists but process not alive', () => {
222285
// Make the session-id file exist with our stable ID
223286
mockExistsSync.mockImplementation((p: string) =>
@@ -320,6 +383,25 @@ describe('ensureOrchestratorMcpConfig', () => {
320383
})
321384
})
322385

386+
describe('orchestrator model preference', () => {
387+
it('falls back to the latest known Claude model when unset', () => {
388+
const sm = fakeSessionManager()
389+
expect(getOrchestratorModel(sm)).toBe('claude-latest-test')
390+
})
391+
392+
it('returns the stored choice when set', () => {
393+
const sm = fakeSessionManager(undefined, { agent_model: 'claude-opus-5' })
394+
expect(getOrchestratorModel(sm)).toBe('claude-opus-5')
395+
})
396+
397+
it('round-trips a saved choice', () => {
398+
const sm = fakeSessionManager()
399+
setOrchestratorModel(sm, 'claude-fable-5')
400+
expect(sm.archive.setSetting).toHaveBeenCalledWith('agent_model', 'claude-fable-5')
401+
expect(getOrchestratorModel(sm)).toBe('claude-fable-5')
402+
})
403+
})
404+
323405
describe('getOrchestratorSessionId', () => {
324406
it('returns null when no ID file exists', () => {
325407
mockExistsSync.mockReturnValue(false)

server/orchestrator-manager.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ import { fileURLToPath } from 'url'
1111
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
1212
import { randomUUID } from 'crypto'
1313
import { DATA_DIR, AGENT_DISPLAY_NAME, getAgentDisplayName } from './config.js'
14+
import { getDefaultClaudeModel } from './anthropic-models.js'
1415
import type { SessionManager } from './session-manager.js'
1516

1617
export const ORCHESTRATOR_DIR = join(DATA_DIR, 'orchestrator')
1718
const SESSION_ID_FILE = join(ORCHESTRATOR_DIR, '.session-id')
1819

20+
/** Archive settings key holding the user's explicit model choice for the agent. */
21+
const MODEL_SETTING_KEY = 'agent_model'
22+
1923
const PROFILE_TEMPLATE = `# User Profile
2024
2125
Agent ${AGENT_DISPLAY_NAME} will learn about you over time and update this file.
@@ -474,6 +478,23 @@ export function isOrchestratorSession(source: string | undefined): boolean {
474478
return source === 'orchestrator'
475479
}
476480

481+
/**
482+
* The model the orchestrator runs on. An explicit choice (made from the chat
483+
* composer) wins; otherwise the agent tracks the latest known Claude model, so
484+
* it never gets stranded on whatever was newest when the session was created.
485+
*/
486+
export function getOrchestratorModel(sessions: SessionManager): string {
487+
return sessions.archive.getSetting(MODEL_SETTING_KEY, '') || getDefaultClaudeModel()
488+
}
489+
490+
/**
491+
* Persist the orchestrator's model choice. The session itself is recreated on
492+
* demand, so the preference has to live outside it.
493+
*/
494+
export function setOrchestratorModel(sessions: SessionManager, model: string): void {
495+
sessions.archive.setSetting(MODEL_SETTING_KEY, model)
496+
}
497+
477498
/**
478499
* Ensure the orchestrator session exists and is running.
479500
* Creates it if missing, starts Claude if not alive.
@@ -483,6 +504,8 @@ export function ensureOrchestratorRunning(sessions: SessionManager): string {
483504
ensureOrchestratorDir()
484505
const stableId = getOrCreateOrchestratorId()
485506

507+
const model = getOrchestratorModel(sessions)
508+
486509
// Check if session already exists
487510
const existing = sessions.get(stableId)
488511
if (existing) {
@@ -495,6 +518,13 @@ export function ensureOrchestratorRunning(sessions: SessionManager): string {
495518
}
496519
// Session exists — start Claude if not alive
497520
if (!existing.claudeProcess?.isAlive()) {
521+
// Adopt the stored/latest model on the way back up. Only safe while the
522+
// process is down: a live process is already bound to its --model flag,
523+
// and setModel() is the path that restarts it.
524+
if (existing.model !== model) {
525+
existing.model = model
526+
sessions.persistToDisk()
527+
}
498528
console.log('[orchestrator] Restarting orchestrator Claude process')
499529
sessions.startClaude(stableId)
500530
}
@@ -509,6 +539,7 @@ export function ensureOrchestratorRunning(sessions: SessionManager): string {
509539
id: stableId,
510540
permissionMode: 'acceptEdits',
511541
allowedTools: ORCHESTRATOR_ALLOWED_TOOLS,
542+
model,
512543
})
513544

514545
// Start Claude

server/ws-message-handler.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
44
// Mock config so REPOS_ROOT covers test paths
55
vi.mock('./config.js', () => ({ REPOS_ROOT: '/projects' }))
66

7+
// The orchestrator manager reads DATA_DIR from the (mocked) config at import
8+
// time, so stub it out — only the two model helpers are used here.
9+
const setOrchestratorModelMock = vi.hoisted(() => vi.fn())
10+
vi.mock('./orchestrator-manager.js', () => ({
11+
isOrchestratorSession: (source: string | undefined) => source === 'orchestrator',
12+
setOrchestratorModel: setOrchestratorModelMock,
13+
}))
14+
715
// Mock fs.realpathSync — defaults to identity (no real filesystem); individual
816
// tests can override the implementation to simulate symlink canonicalization.
917
const realpathSyncMock = vi.fn((p: string) => p)
@@ -447,6 +455,22 @@ describe('handleWsMessage', () => {
447455

448456
expect(ctx.sessions.setModel).not.toHaveBeenCalled()
449457
})
458+
459+
it('does not persist a preference for an ordinary session', () => {
460+
handleWsMessage({ type: 'set_model', model: 'claude-sonnet-4-6' } as WsClientMessage, ctx)
461+
462+
expect(setOrchestratorModelMock).not.toHaveBeenCalled()
463+
})
464+
465+
it('persists the choice when the session is the orchestrator', () => {
466+
const orchestrator = mockSession({ source: 'orchestrator' })
467+
;(ctx.sessions.get as ReturnType<typeof vi.fn>).mockReturnValue(orchestrator)
468+
469+
handleWsMessage({ type: 'set_model', model: 'claude-opus-5' } as WsClientMessage, ctx)
470+
471+
expect(ctx.sessions.setModel).toHaveBeenCalledWith('sess-1', 'claude-opus-5')
472+
expect(setOrchestratorModelMock).toHaveBeenCalledWith(ctx.sessions, 'claude-opus-5')
473+
})
450474
})
451475

452476
/* ---- set_permission_mode ---- */

server/ws-message-handler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { resolve as pathResolve } from 'path'
1111
import type { WebSocket } from 'ws'
1212
import { getDefaultClaudeModel, triggerCliProbeIfNeeded } from './anthropic-models.js'
1313
import { REPOS_ROOT } from './config.js'
14+
import { isOrchestratorSession, setOrchestratorModel } from './orchestrator-manager.js'
1415
import type { SessionManager } from './session-manager.js'
1516
import { VALID_PERMISSION_MODES, VALID_PROVIDERS } from './types.js'
1617
import type { WsClientMessage, WsServerMessage } from './types.js'
@@ -200,6 +201,11 @@ export function handleWsMessage(msg: WsClientMessage, ctx: WsHandlerContext): vo
200201
break
201202
}
202203
sessions.setModel(sessionId, msg.model)
204+
// The orchestrator's model is a standing preference, not a per-session
205+
// one — its session is recreated on demand, so persist the choice.
206+
if (isOrchestratorSession(sessions.get(sessionId)?.source)) {
207+
setOrchestratorModel(sessions, msg.model)
208+
}
203209
}
204210
break
205211
}

src/App.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,13 @@ export default function App({ onSwitchMachine, onDisconnectMachine }: AppProps =
260260
const [claudeDisabled, setClaudeDisabled] = useState(false)
261261
const [openCodeDisabled, setOpenCodeDisabled] = useState(false)
262262
const [codexDisabled, setCodexDisabled] = useState(false)
263-
// Derive the active session's provider (falls back to the default for new sessions)
264-
const activeSessionProvider = sessions.find(s => s.id === activeSessionId)?.provider ?? currentProvider
263+
// Derive the active session's provider (falls back to the default for new
264+
// sessions). The orchestrator session is not in `sessions`, and it always
265+
// runs on Claude — without this the fallback would hand it another
266+
// provider's model list.
267+
const activeSessionProvider = view === 'orchestrator'
268+
? 'claude'
269+
: sessions.find(s => s.id === activeSessionId)?.provider ?? currentProvider
265270

266271
const activeOpenCodeWd = activeSessionProvider === 'opencode'
267272
? sessions.find(s => s.id === activeSessionId)?.workingDir
@@ -763,6 +768,9 @@ export default function App({ onSwitchMachine, onDisconnectMachine }: AppProps =
763768
slashCommands={allCommands}
764769
currentModel={currentModel}
765770
onModelChange={handleModelChange}
771+
/* Claude models, not availableModels: the orchestrator always runs
772+
on Claude, while availableModels follows the default provider. */
773+
availableModels={claudeModels}
766774
currentPermissionMode={currentPermissionMode}
767775
onPermissionModeChange={handlePermissionModeChange}
768776
disabled={!settings.token}

src/components/InputBar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,10 @@ export const InputBar = forwardRef<InputBarHandle, InputBarProps>(function Input
668668
// Session state (left) and actions (right). The orchestrator variant is a
669669
// filter over these plus an accent flag — not a second layout.
670670
const showPermission = !isOrchestrator && !!currentPermissionMode && !!onPermissionModeChange
671-
const showModel = !isOrchestrator && !!currentModel && !!onModelChange
671+
// The orchestrator keeps its model picker — it is one agent, so "what is
672+
// answering me" is as much a question there as in a session. Its harness is
673+
// fixed to Claude, so only the model half of the control applies.
674+
const showModel = !!currentModel && !!onModelChange
672675
const showProvider = !isOrchestrator && !!sessionProvider && !!onProviderChange
673676
// Harness and model read as one fact ("what is answering me"), so they share
674677
// one control; it appears as soon as either half is known.

src/components/OrchestratorContent.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export interface OrchestratorContentProps {
3939
slashCommands: SlashCommand[]
4040
currentModel: string | null
4141
onModelChange: (model: string) => void
42+
/** Claude models offered in the composer — the orchestrator is Claude-only. */
43+
availableModels?: import('../types').ModelOption[]
4244
currentPermissionMode: PermissionMode
4345
onPermissionModeChange: (mode: PermissionMode) => void
4446
disabled: boolean
@@ -68,6 +70,7 @@ export function OrchestratorContent({
6870
slashCommands,
6971
currentModel,
7072
onModelChange,
73+
availableModels,
7174
currentPermissionMode,
7275
onPermissionModeChange,
7376
disabled,
@@ -127,6 +130,7 @@ export function OrchestratorContent({
127130
onValueChange={() => {}}
128131
currentModel={currentModel}
129132
onModelChange={onModelChange}
133+
availableModels={availableModels}
130134
isMobile={isMobile}
131135
currentPermissionMode={currentPermissionMode}
132136
onPermissionModeChange={onPermissionModeChange}

0 commit comments

Comments
 (0)