feat: pass --name to local claude spawns so workers carry their session names as peer names

Version-gated fail-closed at 2.1.224 (the cross-session-messaging release,
flag presence verified against that binary): an unknown or older CLI yields
a spawn command byte-identical to before, because claude aborts startup on
an unknown option and that would kill every session spawn. The value is
allowlist-sanitized ahead of the double-quoted interpolation, and only the
local command carries the flag; docker/remote builders never see it since
their CLI is not the probed binary. Verified E2E on an isolated instance:
cmdline shows --name, ListAgents lists the session name, replies arrive
tagged from-name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-09 13:23:24 +02:00
parent 1e1db947c5
commit 64b33eb630
9 changed files with 316 additions and 14 deletions
+3 -1
View File
@@ -2,4 +2,6 @@
"aicodeman": minor
---
Codeman agent skill: cross-session messaging integration. The skill now teaches agents to drive claude workers over Claude Code's cross-session messaging (`ListAgents`/`SendMessage`, CLI v2.1.224+) where available: map `ListAgents` rows to Codeman sessions via the `tmux codeman-<id8>` column, deliver multi-line exactly-once task messages (including mid-turn steering of a busy worker), collect results as latched replies instead of polling, and fall back to the HTTP recipes whenever the feature is absent (version, feature flag, telemetry-disabling env vars, Docker/remote cases, non-claude modes). Adds `reference/messaging.md` (ships automatically, the skill installer enumerates `reference/*.md`), fan-out Flow 5 in `reference/recipes.md`, new troubleshooting rows in `reference/endpoints.md`, and safety rules for the shared peer namespace (message only workers you created, no permission laundering in either direction). All mechanics verified live against claude-cli 2.1.226.
Cross-session messaging integration, two halves. **Workers now carry their Codeman session names as messaging peer names**: local claude spawns pass `--name <session name>` when the installed CLI is 2.1.224+ (the cross-session-messaging release). The gate is fail-closed, since an older claude aborts startup on an unknown option: an unknown or older version yields a spawn command byte-identical to before, the value is allowlist-sanitized before shell interpolation, and docker/remote spawns never carry the flag (their CLI is not the probed binary). Verified end to end on an isolated instance: the worker lists as its session name in `ListAgents`, and its replies arrive tagged `from-name="<session name>"`.
**The Codeman agent skill teaches cross-session messaging**: drive claude workers over `ListAgents`/`SendMessage` where available, map rows to Codeman sessions via the `tmux codeman-<id8>` column, deliver multi-line exactly-once task messages (including mid-turn steering), collect results as latched replies instead of polling, and fall back to the HTTP recipes whenever the feature is absent (version, feature flag, telemetry-disabling env vars, Docker/remote cases, non-claude modes). Adds `reference/messaging.md` (ships automatically, the installer enumerates `reference/*.md`), fan-out Flow 5 in `reference/recipes.md`, troubleshooting rows in `reference/endpoints.md`, and safety rules for the shared peer namespace (message only workers you created, no permission laundering in either direction). All mechanics verified live against claude-cli 2.1.226.
+19 -4
View File
@@ -738,7 +738,22 @@ Verified live (claude-cli 2.1.226, Linux):
expires unattended (upstream default 5 min), which on a headless worker means the
message silently dies. The skill's backstop covers it.
Deliberately NOT done: passing `claude --name <sessionName>` at spawn so peers carry
Codeman session names. The flag exists in 2.1.226, but gating it against older CLIs
risks the worst regression class (sessions failing to spawn on an unknown flag), so
it stays a follow-up behind a version/flag probe.
Follow-up, landed in the same PR: local claude spawns now pass
`--name <session name>` so peers carry Codeman session names. The gate is
`buildNameCliArgs()` (session-cli-builder.ts), fail-closed at
`CLAUDE_NAME_FLAG_MIN_VERSION = 2.1.224`: that is the messaging release, the flag's
presence there was verified against the installed 2.1.224 binary, and the version
comes from `getClaudeCliVersion()` (null on probe failure and under vitest), so an
older or unknown CLI gets a command byte-identical to before. That matters because
claude aborts startup on an unknown option, which would kill every session spawn.
The value is allowlist-sanitized (Unicode letters/digits plus ` ._:-`, leading
dashes stripped so it cannot parse as another option, 64-char cap, empty result =
flag omitted) before the double-quoted interpolation in `buildSpawnCommand`, and
only the LOCAL command carries it: the docker/remote builders never see it, since
their CLI is not the binary the probe measured. E2E on an isolated instance
(`CODEMAN_INSTANCE`): process cmdline `claude ... --name w9-msgtest`, registry
`name: "w9-msgtest"`, `ListAgents` lists it under that name, a message round-trip
works, and its replies arrive tagged `from-name="w9-msgtest"` (a derived-name
worker's replies carry no `from-name`). A quick-start without `sessionName` has an
empty Codeman name, so the peer name stays derived: agents should name their
workers. Tests: `test/name-flag-injection.test.ts`.
+5 -3
View File
@@ -414,9 +414,11 @@ The shape, each step verified live (probes, failure modes and safety detail in
1. Spawn + readiness over HTTP, unchanged (§3, Flow 1).
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
column; the row's `name [ref]` is the address. No row = messaging is off for that
worker (it is feature-flagged even on matching CLI versions, observed live): fall
back to the HTTP recipes without complaint.
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
2.1.224+ a worker's peer name is its Codeman session name, so pass `sessionName`
in quick-start to pick it; older setups list a name derived from the case folder.
No row = messaging is off for that worker (it is feature-flagged even on matching
CLI versions, observed live): fall back to the HTTP recipes without complaint.
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
the listing (a bare name errors asking for the ref). End the task with a reply
instruction: "when done, reply to the sender of this message with one line:
+11
View File
@@ -61,6 +61,17 @@ your quick-start's `sessionId`. The peer NAME (`msgtest-worker-cf`) is assigned
Claude Code, derived from the case directory's folder name plus a suffix Codeman does
not control: never guess it from the case name, read it from the listing.
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
CLI is 2.1.224+, so a worker's peer name usually IS its Codeman session name
(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`,
and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
peer name stays derived. The flag is fail-closed (older/unknown CLI omits it) and
allowlist-sanitized (a name of only unsafe characters is dropped), and docker/remote
spawns never carry it, which is why the `tmux` column stays the canonical join key
rather than the name.
Scriptable probe + name lookup, against the registry Claude Code maintains (one JSON
object per process in `~/.claude/sessions/<pid>.json`):
+2
View File
@@ -97,6 +97,8 @@ export interface RespawnPaneOptions {
sessionId: string;
workingDir: string;
mode: SessionMode;
/** Session display name; a respawned claude keeps its `--name` peer name (version-gated, local only). */
name?: string;
niceConfig?: NiceConfig;
model?: string;
claudeMode?: ClaudeMode;
+54 -1
View File
@@ -11,6 +11,7 @@
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';
/**
@@ -52,6 +53,53 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
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).
*
@@ -60,6 +108,8 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
* @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(
@@ -67,11 +117,14 @@ export function buildInteractiveArgs(
claudeMode: ClaudeMode,
model?: string,
allowedTools?: string,
effort?: EffortLevel
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;
}
+10 -1
View File
@@ -1406,6 +1406,7 @@ export class Session extends EventEmitter {
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
name: this._name,
niceConfig: this._niceConfig,
model: this._model,
claudeMode: this._claudeMode,
@@ -1710,7 +1711,15 @@ export class Session extends EventEmitter {
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
const args = buildInteractiveArgs(
this.id,
this._claudeMode,
this._model,
this._allowedTools,
this._effort,
this._name,
getClaudeCliVersion()
);
this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(getClaudeBinaryPath(), args, {
name: 'xterm-256color',
+35 -4
View File
@@ -49,7 +49,7 @@ import {
type SessionDocker,
type DockerCommandMode,
} from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
import { buildEffortCliArgs, buildNameCliArgs } from './session-cli-builder.js';
import {
buildSshConnectionArgs,
defaultRemoteCommandForMode,
@@ -73,6 +73,7 @@ import {
wrapWithNice,
SAFE_PATH_PATTERN,
findClaudeDir,
getClaudeCliVersion,
resolveOpenCodeDir,
resolveCodexDir,
resolveGeminiDir,
@@ -752,6 +753,20 @@ function buildEffortSettingsFlag(effort?: EffortLevel): string {
return flag && value ? ` ${flag} '${value}'` : '';
}
/**
* Build the ` --name "<session name>"` shell fragment, or '' when it must be
* omitted. Version-gated FAIL-CLOSED in buildNameCliArgs (an older/unknown CLI
* aborts startup on an unknown flag, which would kill every claude spawn), and
* the value is allowlist-sanitized there, so it contains none of the characters
* that are special inside this double-quoted interpolation. The peer name is a
* soft default (in-session /rename still wins), which is why this rides the
* spawn command rather than any persisted config.
*/
function buildClaudeNameFlag(sessionName: string | undefined, cliVersion: string | null): string {
const [flag, value] = buildNameCliArgs(sessionName, cliVersion);
return flag && value ? ` ${flag} "${value}"` : '';
}
export function buildSpawnCommand(options: {
mode: SessionMode;
sessionId: string;
@@ -764,12 +779,25 @@ export function buildSpawnCommand(options: {
antigravityConfig?: AntigravityConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
sessionName?: string;
/**
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
* (getClaudeCliVersion; null under vitest). Tests inject a value here; the
* docker/remote paths never see this builder's output, which is what keeps the
* gate measuring the RIGHT binary — the local one.
*/
claudeCliVersion?: string | null;
}): string {
if (options.mode === 'claude') {
// Validate model to prevent command injection
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
const effortFlag = buildEffortSettingsFlag(options.effort);
const nameFlag = buildClaudeNameFlag(
options.sessionName,
options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion()
);
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
// fall back to a new session with --session-id so the pane doesn't die.
@@ -777,11 +805,11 @@ export function buildSpawnCommand(options: {
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
if (safeResumeId) {
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}${nameFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`;
return `${resumeCmd} || ${fallbackCmd}`;
}
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`;
}
if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig);
@@ -1789,6 +1817,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
resumeSessionId,
effort,
sessionName: name,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -2016,6 +2045,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
name,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
@@ -2050,6 +2080,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
resumeSessionId,
effort,
sessionName: name,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
+177
View File
@@ -0,0 +1,177 @@
/**
* @fileoverview Tests for the version-gated `--name <session name>` claude spawn flag.
*
* The flag makes a Codeman claude worker's cross-session-messaging peer name equal
* its Codeman session name. The gate MUST be fail-closed: a claude CLI older than
* 2.1.224 aborts startup on an unknown option, which would kill every session spawn,
* so an unknown/absent version must produce a command byte-identical to the
* pre-`--name` one. Covers both spawn paths (buildInteractiveArgs for the direct
* PTY fallback, buildSpawnCommand for the tmux pane command) plus the allowlist
* sanitizer that keeps the double-quoted shell interpolation injection-free.
*/
import { describe, it, expect } from 'vitest';
import {
buildInteractiveArgs,
buildNameCliArgs,
sanitizeCliSessionName,
CLAUDE_NAME_FLAG_MIN_VERSION,
} from '../src/session-cli-builder.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
describe('sanitizeCliSessionName', () => {
it('passes ordinary Codeman session names through', () => {
expect(sanitizeCliSessionName('w1-msgtest-worker')).toBe('w1-msgtest-worker');
expect(sanitizeCliSessionName('w18-claudeman: pi')).toBe('w18-claudeman: pi');
});
it('keeps Unicode letters (CJK session names survive)', () => {
expect(sanitizeCliSessionName('会话-测试 w2')).toBe('会话-测试 w2');
});
it('strips every character that is special inside double quotes', () => {
const cleaned = sanitizeCliSessionName('w1"; $(rm -rf /) `boom` \\ $HOME');
expect(cleaned).toBeDefined();
// The double-quote interpolation in buildSpawnCommand is only safe because
// none of these can survive: " $ ` \ and newlines.
expect(cleaned).not.toMatch(/["$`\\\n\r]/);
expect(cleaned).not.toMatch(/[();/]/);
});
it('strips leading dashes so the value cannot parse as another CLI option', () => {
expect(sanitizeCliSessionName('--resume')).toBe('resume');
expect(sanitizeCliSessionName('-x')).toBe('x');
});
it('collapses whitespace and caps length at 64', () => {
expect(sanitizeCliSessionName('a b\t c')).toBe('a b c');
const long = 'x'.repeat(200);
expect(sanitizeCliSessionName(long)).toHaveLength(64);
});
it('returns undefined when nothing safe remains (flag must be omitted, never --name "")', () => {
expect(sanitizeCliSessionName(undefined)).toBeUndefined();
expect(sanitizeCliSessionName('')).toBeUndefined();
expect(sanitizeCliSessionName('"$`\\')).toBeUndefined();
expect(sanitizeCliSessionName('---')).toBeUndefined();
});
});
describe('buildNameCliArgs version gate', () => {
it('emits the flag from the minimum version up', () => {
// 2.1.224 ships cross-session messaging AND is verified (locally, --help)
// to accept --name; the constant must never drift below it.
expect(CLAUDE_NAME_FLAG_MIN_VERSION).toBe('2.1.224');
expect(buildNameCliArgs('w1-a', '2.1.224')).toEqual(['--name', 'w1-a']);
expect(buildNameCliArgs('w1-a', '2.1.226')).toEqual(['--name', 'w1-a']);
expect(buildNameCliArgs('w1-a', '2.2.0')).toEqual(['--name', 'w1-a']);
expect(buildNameCliArgs('w1-a', '3.0.0')).toEqual(['--name', 'w1-a']);
});
it('FAILS CLOSED below the minimum and on unknown versions', () => {
// An older CLI aborts startup on an unknown flag: [] here is what keeps
// every spawn alive on old installs.
expect(buildNameCliArgs('w1-a', '2.1.223')).toEqual([]);
expect(buildNameCliArgs('w1-a', '2.0.999')).toEqual([]);
expect(buildNameCliArgs('w1-a', '1.0.128')).toEqual([]);
expect(buildNameCliArgs('w1-a', null)).toEqual([]);
expect(buildNameCliArgs('w1-a', undefined)).toEqual([]);
});
it('omits the flag entirely when the name sanitizes away or is absent', () => {
expect(buildNameCliArgs(undefined, '2.1.226')).toEqual([]);
expect(buildNameCliArgs('"$`', '2.1.226')).toEqual([]);
});
});
describe('buildInteractiveArgs with a session name (direct PTY path)', () => {
it('appends --name when the version supports it', () => {
const args = buildInteractiveArgs(
'sid-1',
'dangerously-skip-permissions',
undefined,
undefined,
undefined,
'w1-a',
'2.1.226'
);
const idx = args.indexOf('--name');
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe('w1-a');
});
it('omits --name on an old or unknown version', () => {
expect(
buildInteractiveArgs('sid-1', 'dangerously-skip-permissions', undefined, undefined, undefined, 'w1-a', '2.1.223')
).not.toContain('--name');
expect(
buildInteractiveArgs('sid-1', 'dangerously-skip-permissions', undefined, undefined, undefined, 'w1-a', null)
).not.toContain('--name');
// Version parameter omitted entirely = same fail-closed omission
expect(
buildInteractiveArgs('sid-1', 'dangerously-skip-permissions', undefined, undefined, undefined, 'w1-a')
).not.toContain('--name');
});
});
describe('buildSpawnCommand with a session name (tmux path)', () => {
const base = {
mode: 'claude' as const,
sessionId: 'aaaabbbb-cccc-dddd-eeee-ffff00001111',
claudeMode: 'dangerously-skip-permissions' as const,
};
it('appends a quoted --name when the injected version supports it', () => {
const cmd = buildSpawnCommand({ ...base, sessionName: 'w1-msgtest-worker', claudeCliVersion: '2.1.226' });
expect(cmd).toContain(' --name "w1-msgtest-worker"');
});
it('stays byte-identical to the flagless command on an old version', () => {
const withOld = buildSpawnCommand({ ...base, sessionName: 'w1-a', claudeCliVersion: '2.1.223' });
const without = buildSpawnCommand({ ...base, claudeCliVersion: '2.1.223' });
expect(withOld).toBe(without);
expect(withOld).not.toContain('--name');
});
it('stays byte-identical when the version probe failed (null)', () => {
const cmd = buildSpawnCommand({ ...base, sessionName: 'w1-a', claudeCliVersion: null });
expect(cmd).toBe(buildSpawnCommand({ ...base, claudeCliVersion: null }));
});
it('defaults fail-closed when no version is injected (vitest probe is hermetically null)', () => {
// In production the omitted field resolves through getClaudeCliVersion();
// under vitest that is null by design, which doubles as the fail-closed pin.
const cmd = buildSpawnCommand({ ...base, sessionName: 'w1-a' });
expect(cmd).not.toContain('--name');
});
it('carries the flag in BOTH branches of the resume fallback chain', () => {
const cmd = buildSpawnCommand({
...base,
sessionName: 'w1-a',
claudeCliVersion: '2.1.226',
resumeSessionId: 'aaaabbbb-cccc-dddd-eeee-ffff00001111',
});
const occurrences = cmd.split(' --name "w1-a"').length - 1;
expect(cmd).toContain(' || ');
expect(occurrences).toBe(2);
});
it('sanitizes a hostile name before interpolation', () => {
const cmd = buildSpawnCommand({
...base,
sessionName: 'w1"; rm -rf /; echo "',
claudeCliVersion: '2.1.226',
});
const m = cmd.match(/ --name "([^"]*)"/);
expect(m).not.toBeNull();
// Whatever remains inside the quotes must be inert: no quote/dollar/backtick/
// backslash can survive the allowlist, so the shell sees one literal argv.
expect(m![1]).not.toMatch(/["$`\\;/]/);
});
it('never adds --name to non-claude modes', () => {
const cmd = buildSpawnCommand({ mode: 'shell', sessionId: base.sessionId, sessionName: 'w1-a' });
expect(cmd).not.toContain('--name');
});
});