mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
Claude Code's advisor tool (code.claude.com/docs/en/advisor) lets the session's main model consult a second, stronger model at decision points: before committing to an approach, on a recurring error, and before declaring a task done. Codeman can now start claude sessions with one. - `advisorModel` field on POST /api/sessions, /api/quick-start and /api/ralph-loop/start (fable, opus, sonnet or a full model id in those families; haiku cannot advise and is refused). Stored on the session and persisted, so respawn, boot restore and reboot restore keep it. Remote and docker quick-starts refuse it, as they refuse effort. - App Settings, Models, "Advisor" segment (Default / Sonnet / Opus / Fable), synced as `claudeAdvisorModel`. Run, resume and the Ralph wizard send it. Default sends nothing, leaving the CLI's own /advisor choice in charge. - Carried as the `advisorModel` key in the launch's single --settings JSON, merged with ultracode and the statusLine exporter, never the --advisor flag: `claude --advisor haiku` exits 1 at launch, which would leave a dead pane on every respawn, while the settings key degrades to no advisor. A launch without an advisor is byte-identical to before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
301 lines
13 KiB
TypeScript
301 lines
13 KiB
TypeScript
/**
|
|
* @fileoverview Pure functions for building CLI arguments and environment variables
|
|
* for Claude and OpenCode CLI spawning.
|
|
*
|
|
* Extracted from Session to keep argument construction logic testable and
|
|
* separate from PTY lifecycle management.
|
|
*
|
|
* @module session-cli-builder
|
|
*/
|
|
|
|
import type { ClaudeMode, EffortLevel } from './types.js';
|
|
import { isAdvisorModel, isEffortLevel } from './types.js';
|
|
import { getAugmentedPath } from './utils/index.js';
|
|
import { compareVersions } from './utils/dependency-checker.js';
|
|
import { dataPath } from './config/instance.js';
|
|
import { getCli } from './config/cli-registry/registry.js';
|
|
|
|
/**
|
|
* Build Claude CLI permission flags based on the configured mode.
|
|
* Returns an array of args to pass to the CLI.
|
|
*/
|
|
function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): string[] {
|
|
switch (claudeMode) {
|
|
case 'dangerously-skip-permissions':
|
|
return ['--dangerously-skip-permissions'];
|
|
case 'auto':
|
|
return ['--permission-mode', 'auto'];
|
|
case 'allowedTools':
|
|
if (allowedTools) {
|
|
return ['--allowedTools', allowedTools];
|
|
}
|
|
// Fall back to normal mode if no tools specified
|
|
return [];
|
|
case 'normal':
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the CLI args carrying the effort level as a SOFT default (switchable
|
|
* in-session via /effort). The CLAUDE_CODE_EFFORT_LEVEL env var is deliberately
|
|
* avoided — it hard-locks effort and blocks in-session `/effort` switching.
|
|
*
|
|
* Two carriers are needed because neither covers all levels:
|
|
* - regular levels (incl. `max`) → `--effort <level>` (the settings `effortLevel`
|
|
* key is enum(["low","medium","high","xhigh"]) with .catch(undefined), so `max`
|
|
* would be SILENTLY dropped there)
|
|
* - `ultracode` → `--settings '{"ultracode":true}'` (its own boolean settings key,
|
|
* claude >= 2.1.154; rejected by the --effort flag)
|
|
*/
|
|
export function buildEffortCliArgs(effort?: EffortLevel): string[] {
|
|
if (!effort || !isEffortLevel(effort)) return [];
|
|
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
|
|
}
|
|
|
|
/**
|
|
* The `--settings` keys that switch on Claude Code's advisor tool for one session: a
|
|
* stronger model the main model consults at decision points (code.claude.com/docs/en/advisor).
|
|
* Returns `{}` for an absent or non-allowlisted value, so callers can spread it unconditionally.
|
|
*
|
|
* ⚠️ Carried as the `advisorModel` SETTINGS key, never the `--advisor` flag. The flag EXITS at
|
|
* launch on any pairing the CLI refuses (`claude --advisor haiku` prints "cannot be used as an
|
|
* advisor" and exits 1, as does a Fable advisor still awaiting usage-credit consent), which
|
|
* would leave a dead pane on every spawn and respawn. The settings key degrades instead: the
|
|
* CLI simply does not attach an advisor it cannot use. It is a SOFT default either way:
|
|
* `/advisor` still switches or turns it off inside the running session.
|
|
*
|
|
* ⚠️ Claude Code reads only ONE `--settings` flag per invocation, so this must be merged into
|
|
* the same JSON object as ultracode and the statusLine exporter, never rendered on its own.
|
|
*/
|
|
export function buildAdvisorSettings(advisorModel?: string): { advisorModel?: string } {
|
|
return isAdvisorModel(advisorModel) ? { advisorModel } : {};
|
|
}
|
|
|
|
/**
|
|
* Minimum Claude CLI version for passing `--name` at spawn. 2.1.224 is the release
|
|
* that ships cross-session messaging (the feature that makes the peer name matter),
|
|
* and the flag's presence at exactly this version was verified against the installed
|
|
* binary (`2.1.224 --help` lists `-n, --name`). The gate MUST stay fail-closed: an
|
|
* older or unknown CLI aborts startup on an unknown flag ("error: unknown option"),
|
|
* which would kill every session spawn: so no version means no flag, and the
|
|
* command line stays byte-identical to the pre-`--name` one.
|
|
*/
|
|
export const CLAUDE_NAME_FLAG_MIN_VERSION = '2.1.224';
|
|
|
|
/**
|
|
* Reduce a Codeman session name to a string safe to pass as the Claude CLI
|
|
* `--name` value. Allowlist, not escaping: keeps Unicode letters/digits (CJK
|
|
* session names survive) plus ` . _ : -`, which excludes every character that is
|
|
* special inside the double-quoted shell interpolation buildSpawnCommand uses
|
|
* (`"`, `$`, backslash, backtick) as well as newlines. Leading dashes/punctuation
|
|
* are stripped so the value can never be parsed as another CLI option, and the
|
|
* result is capped at 64 chars. Returns undefined when nothing safe remains;
|
|
* callers must then omit the flag entirely (never send `--name ""`).
|
|
*/
|
|
export function sanitizeCliSessionName(name?: string): string | undefined {
|
|
if (!name) return undefined;
|
|
const cleaned = name
|
|
.replace(/[^\p{L}\p{N} ._:-]/gu, '')
|
|
.replace(/\s+/g, ' ')
|
|
.replace(/^[\s._:-]+/, '')
|
|
.trim()
|
|
.slice(0, 64)
|
|
.trim();
|
|
return cleaned.length > 0 ? cleaned : undefined;
|
|
}
|
|
|
|
/**
|
|
* Build the `--name <session name>` args pair, version-gated and fail-closed.
|
|
* Returns [] unless the CLI version is KNOWN to support the flag (>= 2.1.224):
|
|
* a null/undefined version (probe failed, or running under vitest where
|
|
* getClaudeCliVersion() is hermetically null) yields [], keeping the spawn
|
|
* command identical to a Codeman without this feature. The name itself is a
|
|
* SOFT default, exactly like model and effort: `/rename` in-session still works.
|
|
*/
|
|
export function buildNameCliArgs(sessionName: string | undefined, cliVersion: string | null | undefined): string[] {
|
|
if (!cliVersion || compareVersions(cliVersion, CLAUDE_NAME_FLAG_MIN_VERSION) < 0) return [];
|
|
const name = sanitizeCliSessionName(sessionName);
|
|
return name ? ['--name', name] : [];
|
|
}
|
|
|
|
/**
|
|
* Build args for an interactive Claude CLI session (direct PTY, non-mux fallback).
|
|
*
|
|
* @param sessionId - The Codeman session ID (passed as --session-id to Claude)
|
|
* @param claudeMode - Permission mode for the CLI
|
|
* @param model - Optional model override (e.g., 'opus', 'sonnet')
|
|
* @param allowedTools - Optional comma-separated allowed tools list
|
|
* @param effort - Optional effort level, injected via --settings (overridable in-session)
|
|
* @param sessionName - Optional Codeman session name, passed as `--name` (version-gated)
|
|
* @param cliVersion - Installed Claude CLI version for the `--name` gate (null = omit the flag)
|
|
* @param advisorModel - Optional advisor model, merged into the one `--settings` JSON (see buildAdvisorSettings)
|
|
* @returns Array of CLI arguments
|
|
*/
|
|
export function buildInteractiveArgs(
|
|
sessionId: string,
|
|
claudeMode: ClaudeMode,
|
|
model?: string,
|
|
allowedTools?: string,
|
|
effort?: EffortLevel,
|
|
sessionName?: string,
|
|
cliVersion?: string | null,
|
|
advisorModel?: string
|
|
): string[] {
|
|
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
|
|
if (model) args.push('--model', model);
|
|
const effortArgs = buildEffortCliArgs(effort);
|
|
const advisor = buildAdvisorSettings(advisorModel);
|
|
if (advisor.advisorModel === undefined) {
|
|
args.push(...effortArgs);
|
|
} else if (effortArgs[0] === '--settings') {
|
|
// One --settings flag only: fold the advisor into ultracode's JSON object.
|
|
args.push('--settings', JSON.stringify({ ...JSON.parse(effortArgs[1]), ...advisor }));
|
|
} else {
|
|
args.push(...effortArgs, '--settings', JSON.stringify(advisor));
|
|
}
|
|
args.push(...buildNameCliArgs(sessionName, cliVersion));
|
|
return args;
|
|
}
|
|
|
|
/**
|
|
* Build args for a one-shot Claude CLI prompt (runPrompt mode).
|
|
*
|
|
* @param prompt - The prompt text to send
|
|
* @param model - Optional model override
|
|
* @returns Array of CLI arguments
|
|
*/
|
|
export function buildPromptArgs(
|
|
prompt: string,
|
|
model?: string,
|
|
claudeMode: ClaudeMode = 'dangerously-skip-permissions',
|
|
allowedTools?: string
|
|
): string[] {
|
|
// Respect the session's permission mode instead of always skipping, so a
|
|
// multi-user non-granted user's one-shot runs classifier-guarded (auto) rather
|
|
// than with full bypass. Defaults to skip-permissions (unchanged single-user).
|
|
const args = ['-p', '--verbose', ...buildPermissionArgs(claudeMode, allowedTools), '--output-format', 'stream-json'];
|
|
if (model) {
|
|
args.push('--model', model);
|
|
}
|
|
args.push(prompt);
|
|
return args;
|
|
}
|
|
|
|
/**
|
|
* Build environment variables for Claude CLI processes (direct PTY, non-mux).
|
|
*
|
|
* Augments process.env with:
|
|
* - UTF-8 locale settings
|
|
* - Augmented PATH (includes Claude CLI directory)
|
|
* - xterm-256color terminal type
|
|
* - Codeman session identification vars
|
|
*
|
|
* @param sessionId - The Codeman session ID
|
|
* @returns Environment variables object for pty.spawn
|
|
*/
|
|
export function buildClaudeEnv(sessionId: string): Record<string, string | undefined> {
|
|
const env: Record<string, string | undefined> = {
|
|
...process.env,
|
|
LANG: 'en_US.UTF-8',
|
|
LC_ALL: 'en_US.UTF-8',
|
|
};
|
|
|
|
// The colour and identity vars come from the registry entry, the same source
|
|
// buildEnvExports() and buildMuxAttachEnv() read, so this fallback cannot drift from
|
|
// the tmux pane the way a hand-maintained list here did.
|
|
// ⚠️ This block runs BEFORE Codeman's own keys are assigned, mirroring
|
|
// buildEnvExports(), where `...cliEnv` is emitted ahead of `export CODEMAN_MUX=1`.
|
|
// Applied afterwards it would outrank them: `unset` and `exports` are config
|
|
// (`~/.codeman/clis.json` overrides any entry), so an entry naming
|
|
// CODEMAN_HOOK_SECRET_FILE or PATH would strip or rewrite it on this path while the
|
|
// tmux pane, where Codeman's exports come last, kept its own value.
|
|
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
|
|
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
|
|
const cliEnv = getCli('claude')?.env;
|
|
for (const name of cliEnv?.unset ?? []) delete env[name];
|
|
for (const item of cliEnv?.exports ?? []) {
|
|
// A direct PTY has no mux, so `muxName` has no value to resolve against. Claude
|
|
// declares literals only; an unresolvable engine value is skipped, never guessed.
|
|
const value =
|
|
typeof item.value === 'string'
|
|
? item.value
|
|
: item.value.engine === 'sessionId'
|
|
? sessionId
|
|
: item.value.engine === 'codemanPrefixedSessionId'
|
|
? `codeman_${sessionId}`
|
|
: undefined;
|
|
if (value !== undefined) env[item.name] = value;
|
|
}
|
|
|
|
Object.assign(env, {
|
|
PATH: getAugmentedPath(),
|
|
TERM: 'xterm-256color',
|
|
// Inform Claude it's running within Codeman (helps prevent self-termination)
|
|
CODEMAN_MUX: '1',
|
|
CODEMAN_SESSION_ID: sessionId,
|
|
// CODEMAN_API_URL rides in via the process.env spread when the server has
|
|
// stamped it (WebServer.start()); no fallback: a hardcoded one was the wrong
|
|
// scheme on HTTPS installs, and a present-with-undefined key would serialize
|
|
// as the literal "CODEMAN_API_URL=undefined" (COD-115).
|
|
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
|
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
|
});
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Build environment variables for mux-attached PTY sessions (tmux attach).
|
|
* Lighter than buildClaudeEnv — no PATH augmentation or Codeman vars needed
|
|
* since the mux session already has those set.
|
|
*
|
|
* @param truecolorEnabled - When true, set COLORTERM=truecolor (COD-75 opt-in);
|
|
* otherwise leave COLORTERM unset. Mirrors buildEnvExports() so both paths agree.
|
|
* @returns Environment variables object for pty.spawn
|
|
*/
|
|
export function buildMuxAttachEnv(truecolorEnabled?: boolean): Record<string, string | undefined> {
|
|
const env: Record<string, string | undefined> = {
|
|
...process.env,
|
|
LANG: 'en_US.UTF-8',
|
|
LC_ALL: 'en_US.UTF-8',
|
|
TERM: 'xterm-256color',
|
|
};
|
|
// COD-115: keys to UNSET must be `delete`d, NOT set to `undefined`. On a
|
|
// `{...process.env}` spread the key stays present with value undefined, and node-pty
|
|
// serializes it as the literal string "TMUX=undefined" — a non-empty value that still
|
|
// trips tmux's nesting guard, killing the attach-bridge PTY (exit 1 → respawn loop).
|
|
// The server can be launched from inside tmux; attach clients must never inherit that
|
|
// parent tmux context. (Same fix the working create path uses in tmux-manager.ts.)
|
|
delete env.TMUX;
|
|
delete env.TMUX_PANE;
|
|
delete env.CLAUDECODE;
|
|
if (truecolorEnabled) {
|
|
env.COLORTERM = 'truecolor';
|
|
} else {
|
|
delete env.COLORTERM; // COD-75: unset for non-truecolor (was `: undefined`, same node-pty quirk)
|
|
}
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Build environment variables for a direct shell session (non-mux fallback).
|
|
*
|
|
* @param sessionId - The Codeman session ID
|
|
* @returns Environment variables object for pty.spawn
|
|
*/
|
|
export function buildShellEnv(sessionId: string): Record<string, string | undefined> {
|
|
return {
|
|
...process.env,
|
|
LANG: 'en_US.UTF-8',
|
|
LC_ALL: 'en_US.UTF-8',
|
|
TERM: 'xterm-256color',
|
|
CODEMAN_MUX: '1',
|
|
CODEMAN_SESSION_ID: sessionId,
|
|
// CODEMAN_API_URL rides in via the process.env spread when set; no fallback
|
|
// (same reasoning as buildClaudeEnv above).
|
|
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
|
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
|
};
|
|
}
|