Files
Codeman/src/session-cli-builder.ts
T
Codeman maintainer c211461500 fix: merge-time follow-ups for #409, #404 and #399
#409 (Claude truecolor). The changeset becomes the changelog, and its premise
does not hold on tmux 3.2 or newer. Measured here on tmux 3.4: `default-terminal`
sits at its compiled default of `tmux-256color`, a live claude pane reports
`TERM=tmux-256color`, and supports-color reads that as 256 colors, where
rgb(55,55,55) lands on ESC[48;5;237m — visible, just not the color the theme
named. The invisible block the PR describes needs TERM to resolve to a 16-color
entry: tmux older than 3.2, or a ~/.tmux.conf setting `default-terminal screen`,
which Codeman's own tmux server does read (it passes no -f). Both the changeset
and the invariants paragraph now say that, so the next report here gets paired
with the reporter's tmux -V instead of being read as universal. The change itself
stands on the simpler argument: claude was one of two entries not asking for
truecolor while twelve do.

Also reorders buildClaudeEnv(). It applied the registry's unset/exports AFTER the
whole env was built, so a clis.json entry naming CODEMAN_HOOK_SECRET_FILE or PATH
would strip it on the direct-PTY path while the tmux pane kept it — buildEnvExports()
emits `...cliEnv` ahead of `export CODEMAN_MUX=1` and cannot. The block now runs
first and Codeman's own keys are assigned on top, matching the pane.

#404 (Ctrl+Z trap). Adds the missing changeset, and records what the trap does
not cover: an agent CLI already holds its tty with ISIG off (verified on three
live panes: `susp = ^Z -isig -icanon`), so this is defence for the startup window
rather than a fix for the steady state, and two input paths still reach the PTY
unfiltered — the mobile accessory bar's one-shot Ctrl and the CJK textarea.

#399 (path picker sort). The server sorts by name and cuts at 500, so the client
sorting those 500 by date gives "the newest of the first 500 by name", which is
wrong in exactly the >500-entry folder the date sort exists for. The status line
now says "(first 500 by name)" so the cut is legible, with the reasoning parked
on _sortEntries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 12:45:41 +02:00

271 lines
11 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 { 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];
}
/**
* 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)
* @returns Array of CLI arguments
*/
export function buildInteractiveArgs(
sessionId: string,
claudeMode: ClaudeMode,
model?: string,
allowedTools?: string,
effort?: EffortLevel,
sessionName?: string,
cliVersion?: string | null
): string[] {
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
if (model) args.push('--model', model);
args.push(...buildEffortCliArgs(effort));
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'),
};
}