Merge pull request #530 from Ark0N/feat/advisor-model

feat: Claude advisor tool support (per session, App Settings default, skill workers)

# Conflicts:
#	CLAUDE.md
#	plugins/codeman/skills/codeman/reference/endpoints.md
#	skills/codeman/reference/endpoints.md
#	src/session.ts
#	src/types/session.ts
#	src/web/routes/reboot-restore-routes.ts
#	src/web/server.ts
This commit is contained in:
Codeman maintainer
2026-10-04 23:25:01 +02:00
30 changed files with 518 additions and 57 deletions
+13
View File
@@ -2188,6 +2188,19 @@
<option value="ultracode">Ultracode</option>
</select>
</div>
<div class="set-row set-row-block" data-search="advisor fable opus sonnet second opinion review consult">
<div class="set-row-text">
<span class="set-row-label">Advisor</span>
<span class="set-row-desc">A stronger model Claude consults before big decisions, on repeated errors and before calling a task done. Uses extra tokens. Switchable in-session with /advisor.</span>
</div>
<div class="set-segment" id="appSettingsAdvisorSegment" role="radiogroup" aria-label="Advisor"></div>
<select id="appSettingsClaudeAdvisor" class="set-select set-field-hidden" aria-hidden="true" tabindex="-1">
<option value="">Default</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="fable">Fable</option>
</select>
</div>
</div>
</div>
+2
View File
@@ -1038,6 +1038,7 @@ Object.assign(CodemanApp.prototype, {
const ralphGlobalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings);
const effort = this.getEffortSetting(ralphGlobalSettings);
const advisorModel = this.getAdvisorSetting(ralphGlobalSettings);
const res = await fetch('/api/ralph-loop/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1050,6 +1051,7 @@ Object.assign(CodemanApp.prototype, {
planItems: enabledItems?.length ? enabledItems : undefined,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
}),
});
const data = await res.json();
+13
View File
@@ -169,6 +169,17 @@ Object.assign(CodemanApp.prototype, {
return valid.includes(effort) ? effort : undefined;
},
/**
* Resolve the advisor model for new Claude sessions from global settings.
* Returns 'fable' | 'opus' | 'sonnet', or undefined (= leave it to the CLI's own
* /advisor choice). Sent as the `advisorModel` payload field; the backend merges it
* into the launch's `claude --settings` JSON, so /advisor still switches it in-session.
*/
getAdvisorSetting(globalSettings) {
const advisor = globalSettings?.claudeAdvisorModel;
return ['fable', 'opus', 'sonnet'].includes(advisor) ? advisor : undefined;
},
// ═══════════════════════════════════════════════════════════════
// Quick Start
// ═══════════════════════════════════════════════════════════════
@@ -1965,6 +1976,7 @@ Object.assign(CodemanApp.prototype, {
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const effort = this.getEffortSetting(globalSettings);
const advisorModel = this.getAdvisorSetting(globalSettings);
// Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus
// toggles; both flow as `modelOverride` → the case's .claude/settings.local.json
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
@@ -1980,6 +1992,7 @@ Object.assign(CodemanApp.prototype, {
workingDir, name,
...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}),
})
}).then(r => r.json())
+30 -6
View File
@@ -531,6 +531,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
document.getElementById('appSettingsClaudeAdvisor').value = settings.claudeAdvisorModel ?? '';
// CPU Priority settings
const niceSettings = settings.nice || {};
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
@@ -631,6 +632,7 @@ Object.assign(CodemanApp.prototype, {
this._syncSettingsChips();
this._syncModelCards();
this._syncEffortSegment();
this._syncAdvisorSegment();
// Back to the top of the document (one scroll, not a tab reset). Updates is
// first now: the version this install is running, and whether a newer one is
// waiting, are the two things worth seeing before any preference. The rest of
@@ -722,6 +724,7 @@ Object.assign(CodemanApp.prototype, {
if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return;
this._buildModelCards();
this._buildEffortSegment();
this._buildAdvisorSegment();
// Rebuilt on every open: admin-ui.js appends its Users entry to the rail
// after the first open, and the menu must not drift from the rail.
this._buildSettingsJumpMenu();
@@ -997,8 +1000,28 @@ Object.assign(CodemanApp.prototype, {
},
_buildEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
this._buildSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment');
},
_syncEffortSegment() {
this._syncSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment');
},
_buildAdvisorSegment() {
this._buildSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment');
},
_syncAdvisorSegment() {
this._syncSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment');
},
/**
* Build a radio segment as a view over a hidden <select>, which stays the single
* source of truth for load/save (the same contract as the model cards).
*/
_buildSelectSegment(selectId, segId) {
const select = document.getElementById(selectId);
const seg = document.getElementById(segId);
if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
seg.innerHTML = '';
[...select.options].forEach(opt => {
@@ -1009,16 +1032,16 @@ Object.assign(CodemanApp.prototype, {
btn.textContent = opt.textContent;
btn.addEventListener('click', () => {
select.value = opt.value;
this._syncEffortSegment();
this._syncSelectSegment(selectId, segId);
});
seg.appendChild(btn);
});
seg.dataset.built = '1';
},
_syncEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
_syncSelectSegment(selectId, segId) {
const select = document.getElementById(selectId);
const seg = document.getElementById(segId);
if (!select || !seg) return;
seg.querySelectorAll('button').forEach(btn => {
const on = btn.dataset.value === (select.value || '');
@@ -2372,6 +2395,7 @@ Object.assign(CodemanApp.prototype, {
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
claudeAdvisorModel: document.getElementById('appSettingsClaudeAdvisor').value,
// CPU Priority settings
nice: {
enabled: document.getElementById('appSettingsNiceEnabled').checked,
+3
View File
@@ -3158,6 +3158,7 @@ Object.assign(CodemanApp.prototype, {
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const effort = this.getEffortSetting(globalSettings);
const advisorModel = this.getAdvisorSetting(globalSettings);
// `resumeSessionId` is a Claude conversation UUID (server reads it from
// ~/.claude/projects); an external-CLI row has no such thing, so sending
// it there gets silently ignored while the OMITTED `mode` field defaults
@@ -3207,6 +3208,8 @@ Object.assign(CodemanApp.prototype, {
...modeConfig,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
// The advisor is a claude-only feature; other CLIs would carry it inertly.
...(advisorModel && effectiveMode === 'claude' ? { advisorModel } : {}),
}),
});
const createData = await createRes.json();
+1 -1
View File
@@ -18,7 +18,7 @@
* session record involved. A dropped plan therefore returns the user to
* resuming by hand, one at a time, which is where they are without this
* feature. What the plan held that a transcript does not is the owner, the
* name, the env overrides, the effort and the lineage.
* name, the env overrides, the effort, the advisor model and the lineage.
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session`
* import and no IO, which keeps it unit-testable and cycle-free.
* - Spending is take-then-build: `take()` removes entries synchronously, before
+2
View File
@@ -293,6 +293,7 @@ export function registerRalphRoutes(
planItems,
envOverrides,
effort,
advisorModel,
} = parseBody(RalphLoopStartSchema, req.body);
// Multi-user: cases live in the requesting user's space.
@@ -343,6 +344,7 @@ export function registerRalphRoutes(
allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides,
effort,
advisorModel,
owner: rlOwner,
});
+1
View File
@@ -196,6 +196,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
),
effort: saved.effort,
model: saved.model,
advisorModel: saved.advisorModel,
attachmentHistory:
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory,
+7 -2
View File
@@ -1141,6 +1141,7 @@ export function registerSessionRoutes(
resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort,
advisorModel: body.advisorModel,
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
remote,
owner,
@@ -3411,6 +3412,7 @@ export function registerSessionRoutes(
ompConfig,
envOverrides,
effort,
advisorModel,
parentSessionId,
agentOrigin,
customModel,
@@ -3458,6 +3460,7 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
advisorModel ||
modelOverride !== undefined ||
codexConfig ||
geminiConfig ||
@@ -3471,7 +3474,7 @@ export function registerSessionRoutes(
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
'envOverrides, effort, advisorModel, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
);
}
@@ -3528,6 +3531,7 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
advisorModel ||
codexConfig ||
geminiConfig ||
antigravityConfig ||
@@ -3540,7 +3544,7 @@ export function registerSessionRoutes(
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
'envOverrides, effort, advisorModel, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
);
}
@@ -3995,6 +3999,7 @@ export function registerSessionRoutes(
ompConfig: qsResolvedOmpConfig,
envOverrides: qsCustomModelEnvOverrides,
effort,
advisorModel,
remote,
docker,
resumeSessionId: dockerResumeId,
+27
View File
@@ -24,6 +24,7 @@ import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
import { isAdvisorModel } from '../types/session.js';
// ========== Path Validation ==========
@@ -252,6 +253,20 @@ const safeEnvOverridesSchema = z
*/
const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional();
/**
* Claude advisor model for new sessions: `fable`/`opus`/`sonnet` or a full model id in one of
* those families (isAdvisorModel). Merged into the launch `--settings` JSON as `advisorModel`,
* a soft default that /advisor still switches in-session. The allowlist is also the injection
* guard for the single-quoted `--settings` argument.
*/
const advisorModelSchema = z
.string()
.max(64)
.refine((value) => isAdvisorModel(value), {
message: 'advisorModel must be fable, opus, sonnet or a full claude-fable/opus/sonnet model id',
})
.optional();
// ========== Session Routes ==========
/**
@@ -526,6 +541,8 @@ export const CreateSessionSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
/**
@@ -1070,6 +1087,8 @@ export const QuickStartSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
/**
* Who is spawning this worker (`codeman-skill` from the packaged agent skill), or,
* equivalently, the `X-Codeman-Agent-Origin` header; the body wins when both are
@@ -1396,6 +1415,12 @@ export const SettingsUpdateSchema = z
// auto-reattached.
remoteAutoReconnect: z.boolean().optional(),
thinkingEffort: z.string().max(20).optional(),
/** Advisor model for new Claude sessions ('' = leave it to the CLI's own /advisor choice). */
claudeAdvisorModel: z
.string()
.max(64)
.refine((value) => value === '' || isAdvisorModel(value), { message: 'Invalid advisor model' })
.optional(),
// UI visibility
showFontControls: z.boolean().optional(),
showSystemStats: z.boolean().optional(),
@@ -1897,6 +1922,8 @@ export const RalphLoopStartSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
planItems: z
.array(
z.object({
+1
View File
@@ -3521,6 +3521,7 @@ export class WebServer extends EventEmitter {
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
model: savedState?.model,
advisorModel: savedState?.advisorModel,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though