mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
refactor: pass envOverrides via tmux export instead of disk write
CLAUDE_CODE_EFFORT_LEVEL (and any CLAUDE_CODE_* / OPENCODE_* key) now flows:
UI dropdown → POST /api/sessions { envOverrides }
→ new Session({ envOverrides })
→ this._envOverrides
→ tmux-manager.buildEnvExports appends `export KEY=<shellescape(VALUE)>`
Previously the API wrote envOverrides to <case>/.claude/settings.local.json, which
created stale state (UI dropdown disagreeing with disk) and polluted user project
directories. Now envOverrides are ephemeral spawn-time state, preserved across
respawnPane cycles via this._envOverrides and across server restart via
SessionState.envOverrides in state.json.
Also removes the now-unused updateCaseEnvVars import from session-routes.ts.
This commit is contained in:
@@ -1032,6 +1032,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const enabledItems = config.generatedPlan?.filter(i => i.enabled);
|
||||
|
||||
try {
|
||||
const envOverrides = this.buildEnvOverrides(
|
||||
this.getCaseSettings(config.caseName),
|
||||
this.loadAppSettingsFromStorage()
|
||||
);
|
||||
const res = await fetch('/api/ralph-loop/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1042,6 +1046,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
maxIterations: config.maxIterations || null,
|
||||
enableRespawn: config.enableRespawn,
|
||||
planItems: enabledItems?.length ? enabledItems : undefined,
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
@@ -12,6 +12,22 @@
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Build envOverrides payload from case + global settings.
|
||||
* Single source of truth for the server-side tmux setenv values.
|
||||
* Keys omitted when value is default/falsy — backend treats unset as "no override".
|
||||
*/
|
||||
buildEnvOverrides(caseSettings, globalSettings) {
|
||||
const env = {};
|
||||
if (caseSettings?.agentTeams || globalSettings?.agentTeamsEnabled) {
|
||||
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
||||
}
|
||||
if (globalSettings?.thinkingEffort) {
|
||||
env.CLAUDE_CODE_EFFORT_LEVEL = globalSettings.thinkingEffort;
|
||||
}
|
||||
return env;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Quick Start
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -319,10 +335,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Build env overrides from global + case settings (case overrides global)
|
||||
const caseSettings = this.getCaseSettings(caseName);
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
const envOverrides = {};
|
||||
if (caseSettings.agentTeams || globalSettings.agentTeamsEnabled) {
|
||||
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
||||
}
|
||||
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
|
||||
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
||||
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
|
||||
const modelOverride = useOpus1m ? 'opus[1m]' : '';
|
||||
@@ -526,6 +539,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Quick-start with opencode mode (auto-allow tools by default)
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -533,6 +547,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
@@ -953,11 +953,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
const name = `w${startNumber}-${dirName}`;
|
||||
|
||||
// Create session with resumeSessionId
|
||||
// Create session with resumeSessionId — include envOverrides so resumed
|
||||
// conversations inherit current UI settings (effort, agent teams, etc.).
|
||||
// Match by path (not basename) so linked/renamed cases still resolve correctly.
|
||||
const matchingCase = (this.cases || []).find((c) => c.path === workingDir);
|
||||
const caseName = matchingCase?.name || workingDir.split('/').pop() || '';
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId }),
|
||||
body: JSON.stringify({
|
||||
workingDir,
|
||||
name,
|
||||
resumeSessionId: sessionId,
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (!createData.success) throw new Error(createData.error);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RespawnController } from '../../respawn-controller.js';
|
||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
@@ -268,10 +268,8 @@ export function registerRalphRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = parseBody(
|
||||
RalphLoopStartSchema,
|
||||
req.body
|
||||
);
|
||||
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems, envOverrides } =
|
||||
parseBody(RalphLoopStartSchema, req.body);
|
||||
|
||||
const casePath = join(CASES_DIR, caseName);
|
||||
|
||||
@@ -298,6 +296,11 @@ export function registerRalphRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting.
|
||||
if (envOverrides && Object.keys(envOverrides).length > 0) {
|
||||
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
|
||||
}
|
||||
|
||||
// Create session
|
||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||
const rlModelConfig = await ctx.getModelConfig();
|
||||
@@ -311,6 +314,7 @@ export function registerRalphRoutes(
|
||||
model: rlModelConfig?.defaultModel || undefined,
|
||||
claudeMode: rlClaudeModeConfig.claudeMode,
|
||||
allowedTools: rlClaudeModeConfig.allowedTools,
|
||||
envOverrides,
|
||||
});
|
||||
|
||||
// Configure Ralph tracker
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { writeHooksConfig, updateCaseEnvVars, updateCaseModel } from '../../hooks-config.js';
|
||||
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
@@ -166,9 +166,21 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Write env overrides to .claude/settings.local.json if provided
|
||||
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
|
||||
await updateCaseEnvVars(workingDir, body.envOverrides);
|
||||
// envOverrides flow through Session → tmux setenv (ephemeral, per-session).
|
||||
//
|
||||
// For keys the caller is actively setting, strip any stale disk entry a prior
|
||||
// Codeman version may have written. Scope limited to:
|
||||
// - Claude mode (OpenCode doesn't read .claude/settings.local.json)
|
||||
// - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate
|
||||
// .claude/settings.local.json in arbitrary user repos that POST /api/sessions
|
||||
// can target, because those may have hand-authored values).
|
||||
const canStripDisk =
|
||||
body.mode !== 'opencode' &&
|
||||
body.envOverrides &&
|
||||
Object.keys(body.envOverrides).length > 0 &&
|
||||
workingDir.startsWith(CASES_DIR + '/');
|
||||
if (canStripDisk) {
|
||||
await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides!));
|
||||
}
|
||||
|
||||
// Write model override to .claude/settings.local.json if provided
|
||||
@@ -239,6 +251,7 @@ export function registerSessionRoutes(
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: body.envOverrides,
|
||||
});
|
||||
|
||||
ctx.addSession(session);
|
||||
@@ -854,7 +867,11 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const { prompt, workingDir } = parseBody(QuickRunSchema, req.body, 'Invalid request body');
|
||||
const {
|
||||
prompt,
|
||||
workingDir,
|
||||
envOverrides: runEnvOverrides,
|
||||
} = parseBody(QuickRunSchema, req.body, 'Invalid request body');
|
||||
|
||||
if (!prompt.trim()) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
||||
@@ -873,7 +890,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const session = new Session({ workingDir: dir });
|
||||
const session = new Session({ workingDir: dir, envOverrides: runEnvOverrides });
|
||||
ctx.addSession(session);
|
||||
ctx.store.incrementSessionsCreated();
|
||||
ctx.persistSessionState(session);
|
||||
@@ -910,7 +927,12 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = parseBody(QuickStartSchema, req.body);
|
||||
const {
|
||||
caseName = 'testcase',
|
||||
mode = 'claude',
|
||||
openCodeConfig,
|
||||
envOverrides,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
@@ -962,6 +984,12 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||
// see POST /api/sessions for full rationale).
|
||||
if (mode !== 'opencode' && envOverrides && Object.keys(envOverrides).length > 0) {
|
||||
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
|
||||
}
|
||||
|
||||
// Create a new session with the case as working directory
|
||||
// Apply global Nice priority config and model config from settings
|
||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||
@@ -983,6 +1011,7 @@ export function registerSessionRoutes(
|
||||
claudeMode: qsClaudeModeConfig.claudeMode,
|
||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||
envOverrides,
|
||||
});
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
|
||||
@@ -178,6 +178,7 @@ export const QuickStartSchema = z.object({
|
||||
.optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
});
|
||||
|
||||
// ========== Hook Events ==========
|
||||
@@ -416,6 +417,7 @@ export const FlickerFilterSchema = z.object({
|
||||
export const QuickRunSchema = z.object({
|
||||
prompt: z.string().min(1).max(100000),
|
||||
workingDir: safePathSchema.optional(),
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
});
|
||||
|
||||
/** POST /api/scheduled */
|
||||
@@ -538,6 +540,7 @@ export const RalphLoopStartSchema = z.object({
|
||||
completionPhrase: z.string().max(100).default('COMPLETE'),
|
||||
maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
|
||||
enableRespawn: z.boolean().default(false),
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
planItems: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
+10
-2
@@ -40,7 +40,7 @@ import { execSync } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, type BackgroundTask } from '../session.js';
|
||||
import type { ClaudeMode } from '../types.js';
|
||||
import type { ClaudeMode, SessionState } from '../types.js';
|
||||
import { RespawnController, RespawnConfig } from '../respawn-controller.js';
|
||||
import type { TerminalMultiplexer } from '../mux-interface.js';
|
||||
import { createMultiplexer } from '../mux-factory.js';
|
||||
@@ -731,7 +731,11 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
/** Persists full session state including respawn config to state.json */
|
||||
private _persistSessionStateNow(session: Session): void {
|
||||
const state = session.toState();
|
||||
// See session-manager.updateSessionState: __envOverrides is an internal disk-only
|
||||
// field kept off SessionState to avoid leaking via API broadcasts.
|
||||
const base = session.toState();
|
||||
const envOverrides = session.getEnvOverridesForPersist();
|
||||
const state = (envOverrides ? { ...base, __envOverrides: envOverrides } : base) as SessionState;
|
||||
const controller = this.respawnControllers.get(session.id);
|
||||
if (controller) {
|
||||
const config = controller.getConfig();
|
||||
@@ -1631,6 +1635,9 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Create a session object for this mux session
|
||||
const recoveryClaudeMode = await this.getClaudeModeConfig();
|
||||
// Recover envOverrides from the internal __envOverrides field written by
|
||||
// session-manager (see updateSessionState). Cast to read the non-public field.
|
||||
const savedEnvOverrides = (savedState as { __envOverrides?: Record<string, string> })?.__envOverrides;
|
||||
const session = new Session({
|
||||
id: muxSession.sessionId, // Preserve the original session ID
|
||||
workingDir: muxSession.workingDir,
|
||||
@@ -1641,6 +1648,7 @@ export class WebServer extends EventEmitter {
|
||||
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
|
||||
claudeMode: recoveryClaudeMode.claudeMode,
|
||||
allowedTools: recoveryClaudeMode.allowedTools,
|
||||
envOverrides: savedEnvOverrides,
|
||||
});
|
||||
|
||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
|
||||
Reference in New Issue
Block a user