Skip to content

E2BIG when launching Claude on Linux — --agents JSON exceeds 128KB per-argument kernel limit#809

Merged
acreeger merged 1 commit intomainfrom
fix/issue-797__e2big-agents-arg
Feb 27, 2026
Merged

E2BIG when launching Claude on Linux — --agents JSON exceeds 128KB per-argument kernel limit#809
acreeger merged 1 commit intomainfrom
fix/issue-797__e2big-agents-arg

Conversation

@acreeger
Copy link
Collaborator

Fixes #797

E2BIG when launching Claude on Linux — --agents JSON exceeds 128KB per-argument kernel limit

Description

il spin fails with spawn E2BIG on Linux when launching Claude because the total command-line arguments to claude exceed the kernel's ARG_MAX limit (2MB on Linux).

The launchClaude() function in src/utils/claude.ts passes the system prompt, MCP configs, agents JSON, and allowed tools list as CLI arguments via --append-system-prompt, --mcp-config, --agents, and --allowed-tools. When the system prompt is large (which is common with iloom's template system), the combined argument + environment size exceeds execve()'s limit.

Reproduction

  1. Set up a project with iloom on Linux
  2. Run il start <issue-number> or il spin from a worktree
  3. Observe spawn E2BIG error when Claude is launched
❌ Failed to spin up loom: Claude CLI error: Command failed with E2BIG: claude --model opus --permission-mode bypassPermissions --add-dir /path/to/worktree --add-dir /tmp --append-system-prompt <massive system prompt>...

This reproduces regardless of terminal backend (tested with tmux and direct invocation).

Root Cause

Linux execve() enforces ARG_MAX (typically 2MB) as the combined limit for arguments + environment. The system prompt alone can be very large, and combined with MCP configs, agents JSON, and tool lists, the total easily exceeds this limit.

On macOS, ARG_MAX is 1MB but the effective limit is stack_size/4 which is typically much larger (~16MB), so this issue may not manifest there.

Possible Solutions

  1. Write system prompt to a temp file: Use --append-system-prompt-file <path> (if Claude CLI supports it) instead of inline
  2. Pass large args via stdin: Pipe the configuration to Claude as JSON on stdin instead of CLI args
  3. Use environment variables: Store the system prompt in an env var (though this counts toward the same limit)
  4. Write a wrapper script: Generate a temp script with the full command and execute that

Environment

  • Linux 6.8.0-90-generic (Ubuntu)
  • iloom-cli v0.10.2
  • Node.js v22.12.0
  • ARG_MAX: 2097152

Related


This PR was created automatically by iloom.

@acreeger
Copy link
Collaborator Author

acreeger commented Feb 27, 2026

Complexity Assessment for Issue #797

Quick Scan Results:

  • Review issue description and comments
  • Scan for files affected
  • Assess architectural impact
  • Determine classification

Key Findings

Root Cause: Linux kernel 128KB per-argument limit on execve() — the --agents JSON (215KB) exceeds this single-argument limit.

Approved Approach (from owner comment): Render agents to worktree subfolder (like Swarm mode does), use --agent flag to load agents from disk, pass MCP config from JSON file.

Files Affected:

  1. /Users/adam/Documents/Projects/iloom-cli/main/src/utils/claude.ts (launchClaude function) — lines 207-210 currently pass agents as JSON string
  2. /Users/adam/Documents/Projects/iloom-cli/main/src/lib/AgentManager.ts (formatForCli) — lines 231-235 returns agents object
  3. /Users/adam/Documents/Projects/iloom-cli/main/src/commands/ignite.ts (~1203 lines) — loads and passes agents
  4. Possibly: /Users/adam/Documents/Projects/iloom-cli/main/src/utils/claude.test.ts — tests for launchClaude

Estimated Changes:

  • Modify agent loading/rendering: ~80-100 LOC
  • Update launchClaude to pass --agent per-file instead of single --agents JSON: ~40-60 LOC
  • Update MCP config handling to write to file: ~30-40 LOC
  • Tests for new agent file rendering: ~100-150 LOC
  • Total: ~250-350 LOC across 4 files

Cross-Cutting Changes Detected: YES

  • The fix cascades through multiple layers:
    • Agent serialization logic (AgentManager.formatForCli)
    • Agent passing in launchClaude (need to switch from --agents JSON to --agent file list)
    • File system operations (write agents to worktree subfolder)
    • MCP config handling (switch from JSON args to file-based config)
  • This requires coordinating changes across AgentManager → launchClaude → IgniteCommand

Architectural Signals Triggered: YES

  • Uncertain approach: The owner noted "I'm not sure when that flag was added" for --agent — implementation may require verification of Claude CLI flag support and version constraints
  • New patterns: Writing agents to disk for file-based loading (similar to existing Swarm mode, but new pattern for interactive spin)
  • Integration points: Coordinating multiple flags (--agent multiple times, --mcp-config file path vs JSON)

Risk Assessment: MEDIUM-HIGH

  • Affects core agent/MCP loading pipeline (foundational to multiple workflows)
  • Requires understanding of Claude CLI's --agent flag behavior and limitations
  • File I/O adds potential failure modes (disk space, permissions, cleanup)
  • Impact scope: il spin, il ignite, swarm mode, and any agent-based workflows

Assessment Summary

Complexity: CROSS-CUTTING CHANGES + ARCHITECTURAL SIGNALS TRIGGERED

This appears deceptively simple (4 files, ~250-350 LOC) but involves critical architectural coordination:

  • Multiple system layers affected (agent loading → serialization → CLI argument passing → file system)
  • Uncertain approach (need to verify --agent flag behavior upstream)
  • Integration complexity (coordinating --agent file list with --mcp-config file path, existing swarm patterns)

Complexity Assessment

Classification: COMPLEX

Metrics:

  • Estimated files affected: 4
  • Estimated lines of code: 250-350
  • Breaking changes: No
  • Database migrations: No
  • Cross-cutting changes: Yes (agent loading pipeline spans AgentManager → launchClaude → file system)
  • File architecture quality: Good (files are well-structured, but changes span multiple layers)
  • Architectural signals triggered: Uncertain approach (Claude CLI --agent flag support unclear), New patterns (file-based agent loading for spin)
  • Overall risk level: Medium-High

Reasoning: Cross-cutting changes through the agent/MCP loading pipeline combined with architectural signals (uncertain upstream flag support, new file-based agent pattern) trigger COMPLEX classification despite moderate file and LOC counts. The fix requires coordinating multiple system layers and integration points that extend beyond simple file modifications.

@acreeger
Copy link
Collaborator Author

acreeger commented Feb 27, 2026

Analysis: Issue #797 - E2BIG when launching Claude on Linux

Executive Summary

The --agents CLI flag passes ~215KB of JSON as a single argument, exceeding Linux's ~128KB per-argument kernel limit. The fix is to render agent files to .claude/agents/ in the worktree (with full YAML frontmatter) so Claude Code auto-discovers them as subagents, eliminating the --agents flag entirely. SwarmSetupService already renders agents to disk but strips frontmatter (for --append-system-prompt-file usage); the new approach needs to preserve frontmatter for proper auto-discovery. The --append-system-prompt flag (~80KB) should also move to --append-system-prompt-file as a proactive fix.

Questions and Key Decisions

Question Answer
Should agents be rendered to .claude/agents/ in the worktree (auto-discovered) or loaded via individual --agent flags? Auto-discovery via .claude/agents/ is the right approach. Rendering files there means Claude Code picks them up at session start (priority 2) without any CLI flag. The --agent flag (singular) makes the whole session BE that agent, which is not the intent here.
Should --append-system-prompt also switch to --append-system-prompt-file? Yes. At ~80KB, the system prompt is under the 128KB limit today but fragile. Claude CLI already supports --append-system-prompt-file (print mode only). For interactive mode, it works too per the CLI reference. This is a proactive fix.
Should MCP config also switch from inline JSON to file paths? The --mcp-config flag already supports file paths. Swarm mode already uses generateAndWriteMcpConfigFile() and passes file paths. Applying the same pattern to the non-swarm spin workflow would be consistent and prevent future issues, though current MCP configs are small (~2-5KB each).
Where should rendered agent files go - .claude/agents/ or a temp directory? .claude/agents/ in the worktree, consistent with how Swarm mode renders agents. These files are already gitignored via the pattern **/.claude/agents/iloom-* (added by migration v0.9.8).
Will auto-discovered agents from .claude/agents/ conflict with user-level agents in ~/.claude/agents/? No conflict expected. Claude Code's priority system means project-level agents (.claude/agents/, priority 2) override user-level agents (~/.claude/agents/, priority 3). iloom agents use the iloom- prefix which is unlikely to collide with user agents.

HIGH/CRITICAL Risks

  • Behavioral difference between --agents and auto-discovery: With --agents, subagents exist only for that session (priority 1, highest). With .claude/agents/, they're project-level (priority 2). If the user has agents with the same names defined elsewhere (e.g., in ~/.claude/agents/), the resolution order changes. The iloom- prefix makes this unlikely but worth noting.

Impact Summary

  • 2 files requiring significant modification (src/commands/ignite.ts, src/utils/claude.ts)
  • 1 file requiring new method (src/lib/AgentManager.ts - render agents to disk with frontmatter)
  • 1 file with minor changes (src/utils/mcp.ts - file-based MCP config for non-swarm)
  • Pattern to follow: SwarmSetupService.renderSwarmAgents() for disk rendering, generateAndWriteMcpConfigFile() for MCP file approach
  • Key decision: Remove --agents JSON from CLI args entirely; rely on .claude/agents/ auto-discovery

Complete Technical Reference (click to expand for implementation details)

Problem Space Research

Problem Understanding

Linux kernel enforces a ~128KB per-argument limit on execve() calls. The --agents flag passes all 8 agent definitions (totaling ~215KB as JSON) as a single CLI argument. This triggers E2BIG on Linux. macOS has a higher effective limit (~16MB) so the issue doesn't manifest there.

Architectural Context

The agent loading pipeline spans three layers:

  1. AgentManager loads agent markdown files from templates/agents/, parses YAML frontmatter, applies template variable substitution and settings overrides, then formats for CLI
  2. IgniteCommand calls agentManager.loadAgents() and agentManager.formatForCli(), passes the result to launchClaude()
  3. launchClaude() in src/utils/claude.ts converts the agents object to JSON.stringify() and passes as --agents flag

Claude Code supports auto-discovery of agents from .claude/agents/ directory (priority 2), which means agents placed there are automatically loaded at session start without any CLI flag.

Edge Cases Identified

  • Template variable substitution must happen BEFORE writing to disk (currently done in loadAgents())
  • Settings overrides (per-agent model changes) must be applied before writing
  • Pattern filtering (['*.md', '!iloom-framework-detector.md']) must still be respected
  • The swarm orchestrator in executeSwarmMode() also passes --agents (line 1085) and needs the same fix
  • --append-system-prompt at ~80KB is under the limit but should also be addressed proactively

Codebase Research Findings

Affected Area: CLI argument construction

Entry Point: src/utils/claude.ts:207-210 - --agents flag construction

if (agents) {
    args.push('--agents', JSON.stringify(agents))
}

Dependencies:

  • Uses: AgentManager.formatForCli() which returns Record<string, unknown>
  • Used by: IgniteCommand.executeInternal() (line 475) and IgniteCommand.executeSwarmMode() (line 1074)

Affected Area: Agent loading and formatting

Entry Point: src/lib/AgentManager.ts:231-235 - formatForCli()

This method simply returns the agents object as-is. The heavy lifting is in loadAgents() which:

  1. Reads .md files from templates/agents/ via fast-glob (line 76)
  2. Parses YAML frontmatter to extract name, description, tools, model, color (line 178-219)
  3. Applies template variable substitution to prompts (line 108-119)
  4. Applies settings overrides for per-agent model changes (line 122-145)
  5. Returns AgentConfigs keyed by agent name

Affected Area: Swarm mode agent rendering (PATTERN TO FOLLOW)

Entry Point: src/lib/SwarmSetupService.ts:237-302 - renderSwarmAgents()

This method renders agents to .claude/agents/ directory but strips frontmatter (writes prompt body only, line 295). This is because swarm agents are loaded via --append-system-prompt-file which doesn't parse YAML. The metadata (model, tools) is returned separately for use in claude -p CLI flags.

For the non-swarm fix, agents need to be rendered with frontmatter so Claude Code auto-discovers them as proper subagents.

Affected Area: System prompt passing

Entry Point: src/utils/claude.ts:185-188 - --append-system-prompt flag

if (appendSystemPrompt) {
    args.push('--append-system-prompt', appendSystemPrompt)
}

The ClaudeCliOptions interface (line 64) has appendSystemPrompt?: string but no appendSystemPromptFile option. Claude CLI supports --append-system-prompt-file in both interactive and print modes per the CLI reference.

Affected Area: MCP config passing

Entry Point: src/utils/claude.ts:190-195 - --mcp-config flags (inline JSON)

for (const config of mcpConfig) {
    args.push('--mcp-config', JSON.stringify(config))
}

Swarm mode already writes MCP config to files via generateAndWriteMcpConfigFile() in src/utils/mcp.ts:285-344 and passes file paths to --mcp-config. The non-swarm spin workflow passes inline JSON.

Similar Patterns Found

  • src/lib/SwarmSetupService.ts:316-373 - renderSwarmWorkerAgent() renders a single agent with full frontmatter to .claude/agents/iloom-swarm-worker.md. This is the closest pattern for what the non-swarm path needs.
  • src/utils/mcp.ts:285-344 - generateAndWriteMcpConfigFile() writes merged MCP config to ~/.config/iloom-ai/mcp-configs/ as JSON file.
  • src/migrations/index.ts:74 - Migration v0.9.8 already adds **/.claude/agents/iloom-* to .gitignore.

Agent File Format for Auto-Discovery

Claude Code auto-discovers agents from .claude/agents/ using YAML frontmatter format:

---
name: agent-name
description: When Claude should delegate to this subagent
tools: Read, Glob, Grep, Bash
model: sonnet
color: orange
---

System prompt body here...

The AgentConfig interface (line 12-18 in AgentManager.ts) maps directly:

  • description -> frontmatter description
  • prompt -> markdown body
  • tools -> frontmatter tools (comma-separated string)
  • model -> frontmatter model
  • color -> frontmatter color (optional)

Architectural Flow Analysis

Data Flow: agents (current, broken on Linux)

Entry Point: src/commands/ignite.ts:449 - agentManager.loadAgents(settings, variables, patterns)

Flow Path:

  1. src/commands/ignite.ts:449-454 - IgniteCommand loads agents via loadAgents(), formats via formatForCli()
  2. src/commands/ignite.ts:475-482 - Passes agents object to launchClaude() as ClaudeCliOptions.agents
  3. src/utils/claude.ts:152 - launchClaude() destructures agents from options
  4. src/utils/claude.ts:208-209 - JSON.stringify(agents) passed as single --agents argument (~215KB)
  5. OS execve() - FAILS with E2BIG on Linux (128KB per-arg limit)

Affected Interfaces (all must be updated):

  • ClaudeCliOptions at src/utils/claude.ts:56-80 - Remove or deprecate agents field, potentially add agentsDir field
  • AgentManager.formatForCli() at src/lib/AgentManager.ts:231-235 - Add new method to render to disk instead
  • IgniteCommand.executeInternal() at src/commands/ignite.ts:437-462 - Switch from formatForCli() to disk rendering
  • IgniteCommand.executeSwarmMode() at src/commands/ignite.ts:1050-1061 - Same change for swarm orchestrator path

Data Flow: agents (proposed, file-based)

Entry Point: src/commands/ignite.ts:449 - agentManager.loadAgents(settings, variables, patterns)

Flow Path:

  1. src/commands/ignite.ts:449-454 - IgniteCommand loads agents via loadAgents() (unchanged)
  2. NEW: Render loaded agents to <worktreePath>/.claude/agents/ with full YAML frontmatter
  3. src/commands/ignite.ts:475-482 - Do NOT pass agents to launchClaude() - remove the field
  4. src/utils/claude.ts - Claude launches; auto-discovers agents from .claude/agents/ in the addDir/cwd
  5. No per-arg kernel limit hit; agents are read from disk by Claude Code

Data Flow: systemPrompt (proactive fix)

Entry Point: src/commands/ignite.ts:290 - templateManager.getPrompt() returns ~80KB string

Flow Path:

  1. src/commands/ignite.ts:290 - Gets rendered system prompt
  2. src/commands/ignite.ts:477 - Passes as appendSystemPrompt to launchClaude()
  3. src/utils/claude.ts:186-188 - Passes as --append-system-prompt CLI arg (~80KB)

Proposed: Write to temp file, pass via --append-system-prompt-file

Affected Files

  • src/utils/claude.ts:56-80 - ClaudeCliOptions interface: add appendSystemPromptFile option; agents field should no longer be the primary mechanism
  • src/utils/claude.ts:185-210 - Argument construction: add --append-system-prompt-file support; remove/deprecate --agents JSON path
  • src/lib/AgentManager.ts - Add new method (e.g., renderAgentsToDisk(agents, targetDir)) that writes agent files WITH frontmatter to .claude/agents/
  • src/commands/ignite.ts:437-462 - Switch from formatForCli() + agents option to rendering agents to .claude/agents/ in worktree
  • src/commands/ignite.ts:1050-1085 - Same change for swarm orchestrator's own agent loading
  • src/utils/mcp.ts - Potentially reuse generateAndWriteMcpConfigFile() pattern for non-swarm path (optional)
  • src/utils/claude.test.ts - Update tests for new appendSystemPromptFile and removed agents arg
  • src/commands/ignite.test.ts - Update tests for new agent rendering flow
  • src/lib/AgentManager.test.ts - Add tests for new renderAgentsToDisk method

Integration Points

  • AgentManager.loadAgents() returns AgentConfigs (keyed by name) -> new render method writes these to disk
  • SwarmSetupService.renderSwarmAgents() at src/lib/SwarmSetupService.ts:237 already writes to .claude/agents/ without frontmatter - the new method writes WITH frontmatter
  • .gitignore pattern **/.claude/agents/iloom-* already covers these files (migration v0.9.8)
  • Claude Code auto-discovers .claude/agents/*.md at session start

Medium Severity Risks

  • Template re-rendering on re-spin: If il spin is run multiple times, previously rendered agent files in .claude/agents/ must be overwritten with fresh content (template variables may have changed).
  • Swarm mode dual rendering: The swarm orchestrator both renders agents via SwarmSetupService.renderSwarmAgents() (stripped frontmatter for worker claude -p calls) AND currently loads agents for its own --agents flag. The orchestrator's own agents need the new file-based approach too.
  • File cleanup: Agent files rendered to .claude/agents/ persist in the worktree. They're gitignored but could accumulate if agent names change across iloom versions. Consider cleaning the directory before rendering.

@acreeger
Copy link
Collaborator Author

acreeger commented Feb 27, 2026

Implementation Plan for Issue #797

Summary

The --agents CLI flag passes ~215KB of JSON as a single argument, exceeding Linux's ~128KB per-argument kernel limit causing E2BIG. The fix renders agent files to .claude/agents/ in the worktree with YAML frontmatter so Claude Code auto-discovers them as subagents, eliminating the --agents flag entirely. The --append-system-prompt flag (~80KB) is also moved to --append-system-prompt-file to prevent future issues as prompts grow.

Questions and Key Decisions

Question Answer Rationale
Where should rendered agent files go? .claude/agents/ in the worktree Already gitignored via migration v0.9.8 (**/.claude/agents/iloom-*). Consistent with existing swarm mode pattern.
How does Claude Code discover rendered agents? Auto-discovery from .claude/agents/ (priority 2 in lookup chain) No CLI flag needed. Agent files with YAML frontmatter in this directory are automatically available as subagents.
Where should the system prompt temp file go? os.tmpdir() (e.g., /tmp/iloom-system-prompt-<hash>.md) Avoids needing new gitignore patterns or migrations. File is only needed at launch time.
Should formatForCli() be removed from AgentManager? No, keep but unused Preserves API surface. Both call sites switch to renderAgentsToDisk().
Should the swarm orchestrator get the same fix? Yes -- both executeInternal() and executeSwarmMode() in ignite.ts Both pass --agents and --append-system-prompt to launchClaude().
Will rendered agents conflict with swarm-mode agents in the same .claude/agents/ dir? No Swarm agents are prefixed iloom-swarm-* (no frontmatter, not auto-discovered). Regular agents are iloom-* (with frontmatter, auto-discovered).

High-Level Execution Phases

  1. AgentManager + claude.ts changes: Add renderAgentsToDisk() method; add appendSystemPromptFile to ClaudeCliOptions; add --append-system-prompt-file arg construction; remove --agents arg construction
  2. ignite.ts changes: Both call sites render agents to disk, write system prompt to temp file, use new options
  3. Test updates: Update tests for AgentManager, claude.ts, and ignite.ts to reflect new behavior

Quick Stats

  • 0 files for deletion
  • 4 files to modify (AgentManager.ts, claude.ts, ignite.ts, ignite.test.ts)
  • 0 new files to create
  • Dependencies: None
  • Estimated complexity: Medium

Complete Implementation Guide (click to expand for step-by-step details)

Automated Test Cases to Create

Test File: src/lib/AgentManager.test.ts (MODIFY)

Purpose: Test the new renderAgentsToDisk() method

Click to expand test structure (20 lines)
describe('renderAgentsToDisk', () => {
  it('should write agent files with YAML frontmatter to target directory')
  // Verify: file written to <targetDir>/<agentName>.md
  // Verify: frontmatter contains name, description, tools (comma-separated), model, color
  // Verify: prompt body follows frontmatter separated by blank line

  it('should handle agents without tools field (tools omitted from frontmatter)')
  // Verify: no tools line in frontmatter

  it('should handle agents without color field (color omitted from frontmatter)')
  // Verify: no color line in frontmatter

  it('should create target directory if it does not exist')
  // Verify: fs.ensureDir called

  it('should clean existing iloom agent files before writing')
  // Verify: glob for iloom-*.md in target dir, remove each, then write new

  it('should return list of rendered filenames')
  // Verify: returns string[] of filenames
})

Test File: src/utils/claude.test.ts (MODIFY)

Purpose: Test appendSystemPromptFile option and removal of --agents

Click to expand test structure (15 lines)
describe('appendSystemPromptFile parameter', () => {
  it('should use --append-system-prompt-file flag when appendSystemPromptFile provided')
  // Verify: args include ['--append-system-prompt-file', '/path/to/file']

  it('should omit --append-system-prompt-file when not provided')
  // Verify: args do not contain --append-system-prompt-file

  it('should prefer appendSystemPromptFile over appendSystemPrompt when both provided')
  // Verify: only --append-system-prompt-file appears, not --append-system-prompt
})

// Update existing agents tests:
// - Tests that check for --agents flag should be updated to verify it's NOT passed
// - The 'agents' option in ClaudeCliOptions is deprecated but kept for compatibility

Test File: src/commands/ignite.test.ts (MODIFY)

Purpose: Update agent loading tests to verify disk rendering instead of passing agents to launchClaude

Tests that currently assert launchClaudeCall[1].agents equals an agents object should be updated to verify:

  • mockAgentManager.renderAgentsToDisk was called with correct args
  • launchClaudeCall[1].agents is undefined (no longer passed)
  • launchClaudeCall[1].appendSystemPromptFile is defined (replaces appendSystemPrompt)

Files to Modify

1. src/lib/AgentManager.ts - Add renderAgentsToDisk() method

Lines 1-9 (imports): Add import fs from 'fs-extra' and import fg from 'fast-glob' (fg is already imported).

Actually, fs-extra needs to be added since only fs/promises readFile is currently imported.

After line 235 (after formatForCli): Add new renderAgentsToDisk method.

Click to expand renderAgentsToDisk pseudocode (25 lines)
/**
 * Render loaded agents to disk as markdown files with YAML frontmatter.
 * Claude Code auto-discovers agents from .claude/agents/ directory.
 * Files are named <agentName>.md with full frontmatter.
 *
 * @param agents - Loaded agent configs (from loadAgents())
 * @param targetDir - Absolute path to target directory (e.g., <worktree>/.claude/agents/)
 * @returns Array of rendered filenames
 */
async renderAgentsToDisk(agents: AgentConfigs, targetDir: string): Promise<string[]> {
  await fs.ensureDir(targetDir)

  // Clean existing iloom agent files to avoid stale agents
  const existingFiles = await fg('iloom-*.md', { cwd: targetDir, onlyFiles: true })
  for (const file of existingFiles) {
    await fs.remove(path.join(targetDir, file))
  }

  const renderedFiles: string[] = []
  for (const [agentName, config] of Object.entries(agents)) {
    const filename = `${agentName}.md`
    // Build frontmatter lines
    const frontmatterLines = ['---', `name: ${agentName}`, `description: ${config.description}`]
    if (config.tools) frontmatterLines.push(`tools: ${config.tools.join(', ')}`)
    frontmatterLines.push(`model: ${config.model}`)
    if (config.color) frontmatterLines.push(`color: ${config.color}`)
    frontmatterLines.push('---')

    const content = frontmatterLines.join('\n') + '\n\n' + config.prompt + '\n'
    await fs.writeFile(path.join(targetDir, filename), content, 'utf-8')
    renderedFiles.push(filename)
  }
  return renderedFiles
}

2. src/utils/claude.ts - Add file-based system prompt, deprecate agents flag

Line 1-8 (imports): Add import { writeFile } from 'node:fs/promises' and import { tmpdir } from 'node:os' (for writing system prompt temp files -- actually this is handled by caller, not launchClaude).

Lines 56-80 (ClaudeCliOptions interface):

  • Add new field after line 64: appendSystemPromptFile?: string (path to file containing system prompt)
  • Keep agents field but mark with @deprecated JSDoc comment

Lines 152 (destructuring): Add appendSystemPromptFile to destructured options.

Lines 185-188 (append-system-prompt): Change to prefer file-based:

// Prefer file-based system prompt (avoids per-argument size limits on Linux)
if (appendSystemPromptFile) {
  args.push('--append-system-prompt-file', appendSystemPromptFile)
} else if (appendSystemPrompt) {
  args.push('--append-system-prompt', appendSystemPrompt)
}

Lines 207-210 (agents): Keep the --agents code path but it will no longer be called from ignite.ts. This preserves backward compatibility if any other callers use launchClaude with agents directly.

3. src/commands/ignite.ts - Render agents to disk, write system prompt to file

Line 1-26 (imports): Add imports:

  • import { writeFile } from 'fs/promises' (already imported at line 18 as readFile -- add writeFile)
  • import { tmpdir } from 'os' (for temp system prompt file)
  • import { createHash } from 'crypto' (for temp filename uniqueness)

Lines 437-462 (executeInternal - agent loading, Step 4.6):

Replace the current agent loading block. Instead of formatForCli, call renderAgentsToDisk:

// Step 4.6: Render agent configurations to .claude/agents/ for auto-discovery
try {
  const loadedAgents = await this.agentManager.loadAgents(
    this.settings,
    variables,
    ['*.md', '!iloom-framework-detector.md']
  )
  const agentsDir = path.join(context.workspacePath, '.claude', 'agents')
  const rendered = await this.agentManager.renderAgentsToDisk(loadedAgents, agentsDir)
  logger.debug('Rendered agent files to disk', {
    agentCount: rendered.length,
    agentNames: rendered,
    targetDir: agentsDir,
  })
} catch (error) {
  logger.warn(`Failed to render agents: ${error instanceof Error ? error.message : 'Unknown error'}`)
}

Lines 474-482 (executeInternal - launchClaude call, Step 5):

Write system prompt to temp file and use appendSystemPromptFile:

// Write system prompt to temp file to avoid per-argument size limits on Linux
const promptHash = createHash('md5').update(context.workspacePath).digest('hex').slice(0, 8)
const systemPromptFilePath = path.join(tmpdir(), `iloom-system-prompt-${promptHash}.md`)
await writeFile(systemPromptFilePath, systemInstructions, 'utf-8')

const claudeResult = await launchClaude(userPrompt, {
  ...claudeOptions,
  appendSystemPromptFile: systemPromptFilePath,
  ...(mcpConfig && { mcpConfig }),
  ...(allowedTools && { allowedTools }),
  ...(disallowedTools && { disallowedTools }),
  // agents no longer passed -- auto-discovered from .claude/agents/
})

Lines 1050-1061 (executeSwarmMode - agent loading):

Replace the swarm orchestrator's agent loading with the same disk rendering pattern:

// Render agents to .claude/agents/ for auto-discovery by the orchestrator
try {
  const loadedAgents = await this.agentManager.loadAgents(
    settings,
    variables,
    ['*.md', '!iloom-framework-detector.md']
  )
  const agentsDir = path.join(epicWorktreePath, '.claude', 'agents')
  await this.agentManager.renderAgentsToDisk(loadedAgents, agentsDir)
} catch (error) {
  logger.warn(`Failed to render agents: ${error instanceof Error ? error.message : 'Unknown error'}`)
}

Lines 1074-1092 (executeSwarmMode - launchClaude call):

Write orchestrator prompt to temp file and use appendSystemPromptFile:

// Write orchestrator prompt to temp file
const promptHash = createHash('md5').update(epicWorktreePath).digest('hex').slice(0, 8)
const orchestratorPromptFilePath = path.join(tmpdir(), `iloom-system-prompt-${promptHash}.md`)
await writeFile(orchestratorPromptFilePath, orchestratorPrompt, 'utf-8')

await launchClaude(
  `You are the swarm orchestrator...`,
  {
    model,
    permissionMode: 'bypassPermissions',
    addDir: epicWorktreePath,
    headless: false,
    ...(metadata.sessionId && { sessionId: metadata.sessionId }),
    appendSystemPromptFile: orchestratorPromptFilePath,
    mcpConfig: mcpConfigs,
    allowedTools,
    // agents no longer passed -- auto-discovered from .claude/agents/
    env: { ... },
  },
)

4. src/utils/claude.test.ts - Update tests

Lines 647-783 (appendSystemPrompt parameter describe block):

  • Keep existing tests (backward compatibility)
  • Add new appendSystemPromptFile parameter describe block with tests

Lines 1181-1380 (agents parameter describe block):

  • Keep tests but update to reflect that agents still works as a code path (backward compatibility)
  • The existing tests remain valid since launchClaude still supports --agents when the agents option is passed

5. src/commands/ignite.test.ts - Update agent loading tests

Lines 1291-1460 (agent loading describe block):

  • Update mock agent managers to include renderAgentsToDisk mock
  • Change assertions: instead of checking launchClaudeCall[1].agents, verify renderAgentsToDisk was called
  • Verify launchClaudeCall[1].appendSystemPromptFile is defined
  • Verify launchClaudeCall[1].agents is undefined

6. src/lib/AgentManager.test.ts - Add renderAgentsToDisk tests

Add new describe block after existing tests. Must mock fs-extra (ensureDir, writeFile, remove) and fast-glob.

Detailed Execution Order

Phase 1 (parallel): Core changes to AgentManager and claude.ts

Step 1a: AgentManager - Add renderAgentsToDisk method and tests

Files: src/lib/AgentManager.ts, src/lib/AgentManager.test.ts
Contract: AgentManager.renderAgentsToDisk(agents: AgentConfigs, targetDir: string): Promise<string[]> writes each agent as <name>.md with YAML frontmatter to targetDir, cleans stale iloom-*.md files first, returns list of filenames.

  1. Add import fs from 'fs-extra' to AgentManager.ts (line 1 area)
  2. Add renderAgentsToDisk method after formatForCli (after line 235)
  3. Add test suite for renderAgentsToDisk in AgentManager.test.ts
  4. Verify: pnpm build succeeds

Step 1b: claude.ts - Add appendSystemPromptFile support

Files: src/utils/claude.ts, src/utils/claude.test.ts
Contract: ClaudeCliOptions.appendSystemPromptFile?: string -- when provided, launchClaude passes --append-system-prompt-file <path> instead of --append-system-prompt.

  1. Add appendSystemPromptFile?: string to ClaudeCliOptions (after line 64)
  2. Add @deprecated JSDoc to agents field (line 68)
  3. Add appendSystemPromptFile to destructuring (line 152)
  4. Modify lines 185-188: prefer appendSystemPromptFile over appendSystemPrompt
  5. Add test describe block for appendSystemPromptFile parameter
  6. Verify: pnpm build succeeds

Phase 2 (sequential, depends on Phase 1): ignite.ts and its tests

Step 2: ignite.ts - Switch both call sites to file-based approach

Files: src/commands/ignite.ts, src/commands/ignite.test.ts

  1. Add imports: writeFile from fs/promises, tmpdir from os, createHash from crypto (lines 1-26)
  2. Modify executeInternal Step 4.6 (lines 437-462): replace formatForCli with renderAgentsToDisk
  3. Modify executeInternal Step 5 (lines 474-482): write system prompt to temp file, use appendSystemPromptFile, remove agents from options
  4. Modify executeSwarmMode agent loading (lines 1050-1061): replace formatForCli with renderAgentsToDisk
  5. Modify executeSwarmMode launchClaude call (lines 1074-1092): write orchestrator prompt to temp file, use appendSystemPromptFile, remove agents from options
  6. Update ignite.test.ts agent loading tests (lines 1291-1460): update mock agent managers with renderAgentsToDisk, change assertions
  7. Verify: pnpm build succeeds, pnpm test -- src/commands/ignite.test.ts passes

Execution Plan

  1. Run Steps 1a, 1b in parallel (AgentManager + claude.ts -- different files, shared contract: renderAgentsToDisk(agents: AgentConfigs, targetDir: string): Promise<string[]> and ClaudeCliOptions.appendSystemPromptFile)
  2. Run Step 2 (sequential -- ignite.ts depends on both Step 1a and 1b being complete)

Dependencies and Configuration

None

@acreeger
Copy link
Collaborator Author

acreeger commented Feb 27, 2026

Implementation Plan for Issue #797 (Revised)

Summary

The --agents CLI flag passes ~215KB of JSON as a single argument, exceeding Linux's ~128KB per-argument kernel limit causing E2BIG. The fix uses a platform-specific strategy: macOS is completely unchanged; Linux and Windows render agent .md files with YAML frontmatter to .claude/agents/ in the worktree for auto-discovery (eliminating --agents); Linux keeps --append-system-prompt inline (~80KB, under limit); Windows uses a SessionStart hook plugin via --plugin-dir to inject the system prompt (since --append-system-prompt may also hit limits on Windows), plus /clear as the initial prompt to trigger the hook.

Questions and Key Decisions

Question Answer Rationale
Should macOS code paths change? No. Gate all non-Darwin behavior on process.platform !== 'darwin' macOS has ~16MB effective ARG_MAX; no issue there
How do subagents load on Linux/Windows? Render to .claude/agents/ with full YAML frontmatter Claude Code auto-discovers project-level agents (priority 2). Already gitignored via migration v0.9.8 (**/.claude/agents/iloom-*)
How does system prompt work on Linux? Keep --append-system-prompt inline At ~80KB it's under the 128KB per-argument limit
How does system prompt work on Windows? Write to .claude/iloom-system-prompt.md, use SessionStart hook plugin via --plugin-dir, pass /clear as initial prompt --append-system-prompt inline may also hit limits; SessionStart hooks are broken for new sessions but work for /clear
Does --agent (singular) get used? No. It replaces the entire default system prompt (no inheritance) We want subagents, not replacement of the main prompt
Will rendered agents conflict with swarm-mode agents? No. Swarm agents use iloom-swarm-* prefix (no frontmatter, not auto-discovered). Regular agents use iloom-* prefix (with frontmatter) Different naming conventions prevent collision
Does formatForCli() get removed? No, kept for backward compatibility Preserves API surface; both ignite.ts call sites switch to renderAgentsToDisk()

High-Level Execution Phases

  1. Platform helper: Create a utility function to detect platform and gate behavior
  2. AgentManager.renderAgentsToDisk(): New method to write agents with YAML frontmatter to .claude/agents/ directory
  3. claude.ts changes: Add appendSystemPromptFile and pluginDir to ClaudeCliOptions; add corresponding --append-system-prompt-file and --plugin-dir arg construction
  4. Windows plugin setup: New helper to create the SessionStart hook plugin directory structure
  5. ignite.ts changes: Both executeInternal() and executeSwarmMode() gain platform-gated agent rendering and system prompt handling
  6. Tests: Update tests for AgentManager, claude.ts, and ignite.ts

Quick Stats

  • 0 files for deletion
  • 4 files to modify (AgentManager.ts, claude.ts, ignite.ts, + test files)
  • 1 new file to create (src/utils/system-prompt-writer.ts for platform-specific prompt handling)
  • Dependencies: None
  • Estimated complexity: Medium-Complex

Potential Risks (HIGH/CRITICAL only)

  • Behavioral difference between --agents and auto-discovery: With --agents, subagents have priority 1 (session-level). With .claude/agents/, they're priority 2 (project-level). The iloom- prefix makes naming collisions unlikely, but if a user has identically-named agents in ~/.claude/agents/, resolution order changes on Linux/Windows.

Complete Implementation Guide (click to expand for step-by-step details)

Automated Test Cases to Create

Test File: src/lib/AgentManager.test.ts (MODIFY)

Purpose: Test the new renderAgentsToDisk() method

Click to expand test structure (25 lines)
describe('renderAgentsToDisk', () => {
  it('should write agent files with YAML frontmatter to target directory')
  // Verify: file written to <targetDir>/<agentName>.md
  // Verify: frontmatter contains name, description, tools (comma-separated), model
  // Verify: optional color included when present
  // Verify: prompt body follows frontmatter separated by blank line

  it('should handle agents without tools field (tools omitted from frontmatter)')
  // Verify: no tools line in frontmatter when config.tools is undefined

  it('should handle agents without color field (color omitted from frontmatter)')
  // Verify: no color line in frontmatter when config.color is undefined

  it('should create target directory if it does not exist')
  // Verify: fs.ensureDir called with targetDir

  it('should clean existing iloom-* agent files before writing')
  // Verify: glob for iloom-*.md in target dir, remove each, then write new

  it('should return list of rendered filenames')
  // Verify: returns string[] of filenames like ['iloom-issue-analyzer.md', ...]

  it('should reconstruct tools as comma-separated string in frontmatter')
  // Verify: tools: ['Read', 'Write', 'Edit'] becomes "tools: Read, Write, Edit" in frontmatter

  it('should handle empty agents object')
  // Verify: returns empty array, no files written
})

Test File: src/utils/claude.test.ts (MODIFY)

Purpose: Test appendSystemPromptFile and pluginDir options

Click to expand test structure (20 lines)
describe('appendSystemPromptFile parameter', () => {
  it('should use --append-system-prompt-file when provided')
  // Verify: args include ['--append-system-prompt-file', '/path/to/file']

  it('should prefer appendSystemPromptFile over appendSystemPrompt when both provided')
  // Verify: only --append-system-prompt-file appears, not --append-system-prompt

  it('should omit --append-system-prompt-file when not provided')
  // Verify: args do not contain --append-system-prompt-file
})

describe('pluginDir parameter', () => {
  it('should use --plugin-dir when provided')
  // Verify: args include ['--plugin-dir', '/path/to/plugin']

  it('should omit --plugin-dir when not provided')
  // Verify: args do not contain --plugin-dir
})

Test File: src/utils/system-prompt-writer.test.ts (NEW)

Purpose: Test platform-specific system prompt preparation

Click to expand test structure (20 lines)
describe('prepareSystemPromptForPlatform', () => {
  it('should return inline appendSystemPrompt on darwin')
  // Verify: returns { appendSystemPrompt: '...' }

  it('should return inline appendSystemPrompt on linux')
  // Verify: returns { appendSystemPrompt: '...' }

  it('should write prompt file and return plugin config on win32')
  // Verify: writes .claude/iloom-system-prompt.md
  // Verify: creates .claude/iloom-plugin/ directory
  // Verify: creates .claude/iloom-plugin/hooks.json with SessionStart hook
  // Verify: returns { pluginDir: '...', initialPrompt: '/clear' }
})

describe('createSessionStartPlugin', () => {
  it('should create hooks.json with cat command for system prompt file')
  // Verify: hooks.json structure matches expected SessionStart config

  it('should create plugin directory structure')
  // Verify: .claude/iloom-plugin/ exists with hooks.json
})

Test File: src/commands/ignite.test.ts (MODIFY)

Purpose: Update agent loading tests to verify platform-gated disk rendering

Tests that currently assert launchClaudeCall[1].agents equals an agents object need platform-aware updates:

  • On darwin: verify formatForCli was called and agents is in launchClaude options (unchanged behavior)
  • On non-darwin: verify renderAgentsToDisk was called and agents is NOT in launchClaude options

Since tests run on macOS (darwin), the existing tests should continue to pass with the darwin code path. Add new tests that mock process.platform to verify Linux/Windows behavior.

Files to Modify

1. src/lib/AgentManager.ts - Add renderAgentsToDisk() method

Line 1 (imports): Add import fs from 'fs-extra' and import path from 'path'

After line 235 (after formatForCli): Add new renderAgentsToDisk method

Click to expand renderAgentsToDisk pseudocode (30 lines)
/**
 * Render loaded agents to disk as markdown files with YAML frontmatter.
 * Claude Code auto-discovers agents from .claude/agents/ directory.
 *
 * @param agents - Loaded agent configs (from loadAgents())
 * @param targetDir - Absolute path to target directory (e.g., <worktree>/.claude/agents/)
 * @returns Array of rendered filenames
 */
async renderAgentsToDisk(agents: AgentConfigs, targetDir: string): Promise<string[]> {
  await fs.ensureDir(targetDir)

  // Clean existing iloom agent files to avoid stale agents from previous runs
  const existingFiles = await fg('iloom-*.md', { cwd: targetDir, onlyFiles: true })
  for (const file of existingFiles) {
    await fs.remove(path.join(targetDir, file))
  }

  const renderedFiles: string[] = []
  for (const [agentName, config] of Object.entries(agents)) {
    const filename = `${agentName}.md`
    // Build YAML frontmatter
    const frontmatterLines = ['---', `name: ${agentName}`, `description: ${config.description}`]
    if (config.tools) frontmatterLines.push(`tools: ${config.tools.join(', ')}`)
    frontmatterLines.push(`model: ${config.model}`)
    if (config.color) frontmatterLines.push(`color: ${config.color}`)
    frontmatterLines.push('---')

    const content = frontmatterLines.join('\n') + '\n\n' + config.prompt + '\n'
    await fs.writeFile(path.join(targetDir, filename), content, 'utf-8')
    renderedFiles.push(filename)
  }
  return renderedFiles
}

2. src/utils/claude.ts - Add appendSystemPromptFile, pluginDir, and initialPrompt to ClaudeCliOptions

Lines 56-80 (ClaudeCliOptions interface):

  • After line 64, add: appendSystemPromptFile?: string (path to system prompt file)
  • After line 68, add: pluginDir?: string (path to plugin directory for --plugin-dir flag)
  • Mark agents field (line 68) with @deprecated JSDoc -- still functional but callers should prefer disk rendering

Line 152 (destructuring): Add appendSystemPromptFile, pluginDir to destructured options

Lines 185-188 (append-system-prompt): Change to prefer file-based when provided:

if (appendSystemPromptFile) {
  args.push('--append-system-prompt-file', appendSystemPromptFile)
} else if (appendSystemPrompt) {
  args.push('--append-system-prompt', appendSystemPrompt)
}

After line 210 (after agents block): Add plugin-dir support:

if (pluginDir) {
  args.push('--plugin-dir', pluginDir)
}

Lines 354 (interactive mode prompt passing): Currently uses [...args, '--', prompt]. Need to support the case where prompt might be /clear (for Windows). This should work as-is since /clear is just a string prompt.

3. src/utils/system-prompt-writer.ts (NEW) - Platform-specific system prompt preparation

Purpose: Encapsulates the platform-specific logic for how the system prompt reaches Claude. This keeps the platform branching out of ignite.ts.

Click to expand system-prompt-writer pseudocode (55 lines)
import path from 'path'
import fs from 'fs-extra'

/**
 * Result of preparing the system prompt for a specific platform.
 * Exactly one of these strategies will be populated.
 */
export interface SystemPromptConfig {
  /** Inline system prompt (macOS + Linux) */
  appendSystemPrompt?: string
  /** File-based system prompt (Windows fallback if needed) */
  appendSystemPromptFile?: string
  /** Plugin directory for --plugin-dir (Windows) */
  pluginDir?: string
  /** Override the initial user prompt (Windows: '/clear' to trigger SessionStart) */
  initialPromptOverride?: string
}

/**
 * Prepare the system prompt for the current platform.
 *
 * - darwin: inline via --append-system-prompt (unchanged)
 * - linux: inline via --append-system-prompt (80KB < 128KB limit)
 * - win32: write to file, create SessionStart plugin, pass /clear
 */
export async function prepareSystemPromptForPlatform(
  systemPrompt: string,
  workspacePath: string,
  platform: string = process.platform,
): Promise<SystemPromptConfig> {
  if (platform === 'darwin' || platform === 'linux') {
    // macOS and Linux: inline system prompt
    return { appendSystemPrompt: systemPrompt }
  }

  // Windows: write system prompt to file, create SessionStart hook plugin
  const claudeDir = path.join(workspacePath, '.claude')
  const promptFilePath = path.join(claudeDir, 'iloom-system-prompt.md')
  const pluginDir = path.join(claudeDir, 'iloom-plugin')

  await fs.ensureDir(claudeDir)
  await fs.writeFile(promptFilePath, systemPrompt, 'utf-8')

  // Create plugin directory with SessionStart hook
  await fs.ensureDir(pluginDir)
  const hooksConfig = {
    hooks: {
      SessionStart: [
        {
          matcher: '*',
          hooks: [
            {
              type: 'command',
              command: `cat "${promptFilePath}"`,
            },
          ],
        },
      ],
    },
  }
  await fs.writeFile(
    path.join(pluginDir, 'hooks.json'),
    JSON.stringify(hooksConfig, null, 2),
    'utf-8',
  )

  return {
    pluginDir,
    initialPromptOverride: '/clear',
  }
}

4. src/commands/ignite.ts - Platform-gated agent rendering and system prompt handling

Line 1-26 (imports): Add:

  • import { prepareSystemPromptForPlatform } from '../utils/system-prompt-writer.js'

Lines 437-462 (executeInternal - Step 4.6, agent loading):

Replace the current agent loading block with platform-gated logic:

Click to expand revised Step 4.6 pseudocode (30 lines)
// Step 4.6: Load agent configurations using cached settings
let agents: Record<string, unknown> | undefined
try {
  // ... existing settings debug logging (lines 441-445 unchanged) ...

  const loadedAgents = await this.agentManager.loadAgents(
    this.settings,
    variables,
    ['*.md', '!iloom-framework-detector.md']
  )

  if (process.platform === 'darwin') {
    // macOS: pass agents inline via --agents flag (unchanged behavior)
    agents = this.agentManager.formatForCli(loadedAgents)
    logger.debug('Loaded agent configurations for CLI', {
      agentCount: Object.keys(agents).length,
      agentNames: Object.keys(agents),
    })
  } else {
    // Linux/Windows: render agents to .claude/agents/ for auto-discovery
    const agentsDir = path.join(context.workspacePath, '.claude', 'agents')
    const rendered = await this.agentManager.renderAgentsToDisk(loadedAgents, agentsDir)
    logger.debug('Rendered agent files to disk for auto-discovery', {
      agentCount: rendered.length,
      agentNames: rendered,
      targetDir: agentsDir,
    })
    // agents remains undefined - not passed to launchClaude
  }
} catch (error) {
  logger.warn(`Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`)
}

Lines 474-482 (executeInternal - Step 5, launchClaude call):

Add platform-specific system prompt handling before the launchClaude call:

Click to expand revised Step 5 pseudocode (20 lines)
// Prepare system prompt based on platform
const systemPromptConfig = await prepareSystemPromptForPlatform(
  systemInstructions,
  context.workspacePath,
)

// Determine the initial user prompt (Windows overrides with /clear)
const effectiveUserPrompt = systemPromptConfig.initialPromptOverride ?? userPrompt

// Step 5: Launch Claude
const claudeResult = await launchClaude(effectiveUserPrompt, {
  ...claudeOptions,
  ...systemPromptConfig.appendSystemPrompt && { appendSystemPrompt: systemPromptConfig.appendSystemPrompt },
  ...systemPromptConfig.pluginDir && { pluginDir: systemPromptConfig.pluginDir },
  ...(mcpConfig && { mcpConfig }),
  ...(allowedTools && { allowedTools }),
  ...(disallowedTools && { disallowedTools }),
  ...(agents && { agents }),  // Only populated on macOS
})

Lines 1050-1061 (executeSwarmMode - agent loading):

Same platform-gated pattern as executeInternal:

Click to expand revised swarm agent loading pseudocode (25 lines)
// Load agents for the orchestrator
let agents: Record<string, unknown> | undefined
try {
  const loadedAgents = await this.agentManager.loadAgents(
    settings,
    variables,
    ['*.md', '!iloom-framework-detector.md']
  )

  if (process.platform === 'darwin') {
    agents = this.agentManager.formatForCli(loadedAgents)
  } else {
    const agentsDir = path.join(epicWorktreePath, '.claude', 'agents')
    await this.agentManager.renderAgentsToDisk(loadedAgents, agentsDir)
  }
} catch (error) {
  logger.warn(`Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`)
}

Lines 1074-1092 (executeSwarmMode - launchClaude call):

Add platform-specific system prompt handling:

Click to expand revised swarm launchClaude pseudocode (20 lines)
// Prepare orchestrator prompt based on platform
const orchestratorPromptConfig = await prepareSystemPromptForPlatform(
  orchestratorPrompt,
  epicWorktreePath,
)

const effectiveSwarmPrompt = orchestratorPromptConfig.initialPromptOverride
  ?? `You are the swarm orchestrator for epic #${epicIssueNumber}. Begin by reading your system prompt instructions and executing the workflow.`

await launchClaude(effectiveSwarmPrompt, {
  model,
  permissionMode: 'bypassPermissions',
  addDir: epicWorktreePath,
  headless: false,
  ...(metadata.sessionId && { sessionId: metadata.sessionId }),
  ...orchestratorPromptConfig.appendSystemPrompt && { appendSystemPrompt: orchestratorPromptConfig.appendSystemPrompt },
  ...orchestratorPromptConfig.pluginDir && { pluginDir: orchestratorPromptConfig.pluginDir },
  mcpConfig: mcpConfigs,
  allowedTools,
  ...(agents && { agents }),  // Only populated on macOS
  env: {
    CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
    ILOOM_SWARM: '1',
    ENABLE_TOOL_SEARCH: 'auto:30',
  },
})

5. src/utils/claude.test.ts - Add tests for new options

After line 784 (after appendSystemPrompt parameter describe block): Add new describe blocks for appendSystemPromptFile and pluginDir parameters.

Lines 1181-1381 (existing agents parameter tests): Keep all existing tests unchanged. The agents option still works in launchClaude -- it's just that ignite.ts won't pass it on non-Darwin platforms.

6. src/commands/ignite.test.ts - Update agent loading tests

Lines 1291-1460 (agent loading describe block):

Since tests run on macOS (darwin), existing tests should continue to pass (the darwin code path uses formatForCli + agents option, which is unchanged). Add new tests that mock process.platform to verify Linux/Windows behavior:

Click to expand new test cases (25 lines)
it('should render agents to disk on Linux instead of passing to launchClaude', async () => {
  const originalPlatform = process.platform
  Object.defineProperty(process, 'platform', { value: 'linux' })

  try {
    // ... setup with mockAgentManager that has renderAgentsToDisk mock ...
    // Verify: mockAgentManager.renderAgentsToDisk was called
    // Verify: launchClaudeCall[1].agents is undefined
  } finally {
    Object.defineProperty(process, 'platform', { value: originalPlatform })
  }
})

it('should use plugin-dir and /clear prompt on Windows', async () => {
  const originalPlatform = process.platform
  Object.defineProperty(process, 'platform', { value: 'win32' })

  try {
    // ... setup ...
    // Verify: mockAgentManager.renderAgentsToDisk was called
    // Verify: launchClaudeCall[1].pluginDir is defined
    // Verify: launchClaudeCall[0] === '/clear' (initial prompt override)
    // Verify: launchClaudeCall[1].agents is undefined
    // Verify: launchClaudeCall[1].appendSystemPrompt is undefined
  } finally {
    Object.defineProperty(process, 'platform', { value: originalPlatform })
  }
})

7. src/lib/AgentManager.test.ts - Add renderAgentsToDisk tests

After line 985 (end of file): Add new describe block. Must add vi.mock('fs-extra') at top of file (alongside existing vi.mock('fs/promises')).

New Files to Create

src/utils/system-prompt-writer.ts (NEW)

Purpose: Encapsulates platform-specific system prompt delivery strategy. Keeps platform branching logic out of ignite.ts for cleaner separation of concerns.

Content Structure: See pseudocode in Section 4 above (the prepareSystemPromptForPlatform function).

src/utils/system-prompt-writer.test.ts (NEW)

Purpose: Test platform-specific system prompt preparation with mocked process.platform and mocked fs-extra.

Detailed Execution Order

Phase 1 (parallel): Foundation - AgentManager, claude.ts, system-prompt-writer

Step 1a: AgentManager - Add renderAgentsToDisk method and tests

Files: src/lib/AgentManager.ts, src/lib/AgentManager.test.ts
Contract: AgentManager.renderAgentsToDisk(agents: AgentConfigs, targetDir: string): Promise<string[]> - writes each agent as <name>.md with YAML frontmatter to targetDir, cleans stale iloom-*.md files first, returns list of filenames.

  1. Add import fs from 'fs-extra' and import path from 'path' to imports (line 1 area) -> Verify: compiles
  2. Add renderAgentsToDisk method after formatForCli (after line 235) -> Verify: pnpm build succeeds
  3. Add vi.mock('fs-extra') to test file imports, add renderAgentsToDisk describe block after line 985 -> Verify: pnpm test -- src/lib/AgentManager.test.ts passes

Step 1b: claude.ts - Add appendSystemPromptFile and pluginDir support

Files: src/utils/claude.ts, src/utils/claude.test.ts
Contract: ClaudeCliOptions gains appendSystemPromptFile?: string and pluginDir?: string. When appendSystemPromptFile is provided, it takes precedence over appendSystemPrompt. When pluginDir is provided, --plugin-dir is added to args.

  1. Add appendSystemPromptFile?: string and pluginDir?: string to ClaudeCliOptions (lines 64-68 area) -> Verify: compiles
  2. Add appendSystemPromptFile and pluginDir to destructuring at line 152
  3. Modify lines 185-188: prefer appendSystemPromptFile over appendSystemPrompt
  4. Add --plugin-dir arg construction after line 210
  5. Add test describe blocks for appendSystemPromptFile parameter and pluginDir parameter -> Verify: pnpm test -- src/utils/claude.test.ts passes

Step 1c: system-prompt-writer - Create new utility

Files: src/utils/system-prompt-writer.ts (NEW), src/utils/system-prompt-writer.test.ts (NEW)
Contract: prepareSystemPromptForPlatform(systemPrompt: string, workspacePath: string, platform?: string): Promise<SystemPromptConfig> - returns platform-appropriate config for system prompt delivery.

  1. Create src/utils/system-prompt-writer.ts with prepareSystemPromptForPlatform and SystemPromptConfig interface
  2. Create src/utils/system-prompt-writer.test.ts with tests for darwin, linux, and win32 platforms
  3. Verify: pnpm build succeeds, pnpm test -- src/utils/system-prompt-writer.test.ts passes

Phase 2 (sequential, depends on Phase 1): ignite.ts integration

Step 2: ignite.ts - Platform-gated agent rendering and system prompt handling

Files: src/commands/ignite.ts, src/commands/ignite.test.ts

  1. Add import for prepareSystemPromptForPlatform (line 1-26 area)
  2. Modify executeInternal Step 4.6 (lines 437-462): add platform gate around agent loading
  3. Modify executeInternal Step 5 (lines 474-482): use prepareSystemPromptForPlatform for system prompt, use effectiveUserPrompt
  4. Modify executeSwarmMode agent loading (lines 1050-1061): same platform gate
  5. Modify executeSwarmMode launchClaude call (lines 1074-1092): use prepareSystemPromptForPlatform for orchestrator prompt
  6. Update ignite.test.ts: add renderAgentsToDisk mock to mockAgentManager objects. Add new tests for Linux/Windows platform behavior
  7. Verify: pnpm build succeeds, pnpm test -- src/commands/ignite.test.ts passes

Execution Plan

  1. Run Steps 1a, 1b, 1c in parallel (AgentManager + claude.ts + system-prompt-writer -- all different files, shared contracts defined above)
  2. Run Step 2 (sequential -- ignite.ts depends on all three Phase 1 steps being complete since it imports from all three)

Dependencies and Configuration

None

@acreeger
Copy link
Collaborator Author

Implementation Progress

  • Step 1a: AgentManager — renderAgentsToDisk() method + tests (in progress)
  • Step 1b: claude.ts — appendSystemPromptFile and pluginDir CLI options + tests (in progress)
  • Step 1c: system-prompt-writer — new platform-specific utility + tests (in progress)
  • Step 2: ignite.ts — platform-gated agent rendering and system prompt handling + tests (blocked on 1a-1c)

@acreeger
Copy link
Collaborator Author

Code Review Fixes - Implementation

  • Fix 1: Command injection in system-prompt-writer.ts - use runner.js file instead of node -e
  • Fix 2: Path traversal in AgentManager.ts - add path.basename() sanitization
  • Fix 3: Remove unused appendSystemPromptFile from claude.ts
  • Update tests for all three fixes
  • Build and test pass

ETA: ~10 minutes

On Linux, the --agents JSON (~215KB) exceeds the ~128KB per-argument
kernel limit. On Windows, the ~32KB total command line limit is even
tighter.

Platform-specific strategy:
- macOS: unchanged (inline --agents and --append-system-prompt)
- Linux: render agents to .claude/agents/ for auto-discovery, keep
  system prompt inline (80KB < 128KB limit)
- Windows: render agents to .claude/agents/, use SessionStart hook
  plugin via --plugin-dir for system prompt injection with /clear
  as initial prompt trigger

Changes:
- AgentManager.renderAgentsToDisk(): writes agent .md files with YAML
  frontmatter for Claude Code auto-discovery
- system-prompt-writer: new utility for platform-specific system prompt
  delivery (inline on macOS/Linux, SessionStart hook on Windows)
- claude.ts: add pluginDir option for --plugin-dir flag
- ignite.ts: platform-gated agent rendering and system prompt handling
  in both executeInternal() and executeSwarmMode()

Fixes #797
@acreeger acreeger force-pushed the fix/issue-797__e2big-agents-arg branch from c4161f0 to a797cac Compare February 27, 2026 03:12
@acreeger
Copy link
Collaborator Author

Implementation Complete

Summary

Render agents to disk on non-Darwin platforms to avoid E2BIG error. macOS is completely unchanged. Linux renders agents to .claude/agents/ for auto-discovery while keeping the system prompt inline. Windows renders agents to disk AND uses a SessionStart hook plugin via --plugin-dir with /clear as initial prompt to inject the system prompt.

Changes Made

  • src/lib/AgentManager.ts: Added renderAgentsToDisk() method — writes agent .md files with YAML frontmatter to .claude/agents/ for Claude Code auto-discovery
  • src/utils/system-prompt-writer.ts: New utility for platform-specific system prompt delivery (inline on macOS/Linux, SessionStart hook plugin on Windows)
  • src/utils/claude.ts: Added pluginDir option to ClaudeCliOptions for --plugin-dir flag
  • src/commands/ignite.ts: Platform-gated agent rendering and system prompt handling in both executeInternal() and executeSwarmMode()

Validation Results

  • ✅ Tests: 4482 passed (132 test files)
  • ✅ Typecheck: Passed
  • ✅ Lint: Passed
  • ✅ Build: Passed

Detailed Changes by File (click to expand)

src/lib/AgentManager.ts (+36 lines)

Changes: Added renderAgentsToDisk(agents, targetDir) method

  • Writes each agent as <name>.md with YAML frontmatter (name, description, tools, model, color)
  • Cleans stale iloom-*.md files before writing
  • Uses path.basename() for agent name sanitization
  • Returns array of rendered filenames

src/utils/system-prompt-writer.ts (NEW, ~80 lines)

Changes: New platform-specific system prompt utility

  • prepareSystemPromptForPlatform() — returns inline prompt on darwin/linux, plugin config on win32
  • createSessionStartPlugin() — creates plugin dir with hooks.json and runner.js
  • Uses Node.js runner script with JSON.stringify for path safety (no command injection)

src/utils/claude.ts (+10 lines)

Changes: Added pluginDir option

  • ClaudeCliOptions gains pluginDir?: string
  • When set, adds --plugin-dir <path> to CLI args

src/commands/ignite.ts (+90/-28 lines)

Changes: Platform-gated agent and system prompt handling

  • executeInternal(): darwin=formatForCli, non-darwin=renderAgentsToDisk + prepareSystemPromptForPlatform
  • executeSwarmMode(): same platform-gating pattern
  • Windows: uses /clear as effective user prompt to trigger SessionStart hook

Test files (+453 lines)

  • AgentManager.test.ts: 8 new tests for renderAgentsToDisk
  • system-prompt-writer.test.ts: 12 tests for platform-specific behavior
  • claude.test.ts: tests for pluginDir option
  • ignite.test.ts: 3 platform-specific tests (Linux, Windows, macOS unchanged)

@acreeger acreeger merged commit a797cac into main Feb 27, 2026
1 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

E2BIG when launching Claude on Linux — --agents JSON exceeds 128KB per-argument kernel limit

1 participant