fix(claudecode): use --append-system-prompt-file to fix Windows cmdline limit (#1376) (#1378)

cc-connect's built-in AgentSystemPrompt is ~9KB, which on its own
exceeds Windows cmd.exe's 8192-byte command-line limit when claude
is spawned with --append-system-prompt <inline content>. The
claude.exe child immediately fails with GBK stderr "命令行太长。"
(command line too long) before any user message reaches the agent.
On v1.3.3 this breaks every Feishu / IM message on Windows hosts.

Pass the merged prompt through Claude Code CLI's
--append-system-prompt-file <path> flag instead of the inline form.
Only the short file path travels on the command line, so the cap is
no longer a function of prompt size.

Common case (99% of users — no platform formatting hints, no
user-configured append_system_prompt):

  * On agent construction, write the full AgentSystemPrompt() once to
    <data_dir>/agent-prompts/cc-connect-system.md (default
    ~/.cc-connect/agent-prompts/cc-connect-system.md). claude reads
    this file at every spawn; cc-connect never rewrites it unless the
    cc-connect version actually changes the prompt content.
  * Every spawn reuses the same file, so there is no per-spawn write
    and no concurrency race (claude is a strict reader).

Edge case (Slack / Weixin / MAX with FormattingInstructions, or
project-level append_system_prompt):

  * Write a per-spawn temp file under <data_dir>/agent-prompts/
    holding the merged content, passed via the same flag.
  * Cleaned up on session Close to avoid leaking files.

Notes for reviewers:

  * claude CLI refuses --append-system-prompt and
    --append-system-prompt-file together
    ("Cannot use both ... Please use only one"), so the merged content
    must travel as a single file.
  * Only the claudecode agent runtime is affected. Other agents
    (codex/opencode/qoder/cursor/gemini/kimi) inject AgentSystemPrompt
    via memory files (CLAUDE.md/AGENTS.md/...), which already have no
    cmdline cap.
  * core/interfaces.go (AgentSystemPrompt content) is unchanged from
    v1.3.3; the full prompt is preserved so agent behaviour does not
    regress.

Tests:

  * TestEnsureSharedSystemPromptFile_WritesOnceAndReuses — verifies the
    shared file is written once and skipped on identical content.
  * TestEnsureSharedSystemPromptFile_RewritesOnContentChange — verifies
    cc-connect upgrades refresh the shared file.
  * TestEnsureSharedSystemPromptFile_EmptyDirUsesTempDir — verifies the
    fallback when ccDataDir is unset.
  * TestWriteTempAppendPromptFile_UniquePerCall — verifies concurrent
    edge-case spawns get unique paths.
  * Existing claudecode + core suites all pass (one pre-existing flake
    in TestCUJ_H2_TwoPlatformsConcurrentNoBleed is unrelated to this
    change and still passes when run in isolation).

E2E (Windows, MiniMax-M3 backend, Feishu platform):

  * Before fix: every Feishu message produces no reply; claude.exe
    fails with "命令行太长。" on stderr.
  * After fix: "你好" → MiniMax intro reply; "3 分钟后帮我看看
    ipconfig" → timer correctly created in data/timers/jobs.json;
    "每天早上 6 点提醒我吃药" → cron correctly created in
    data/crons/jobs.json. Agent uses /timer and /cron commands as
    instructed by the prompt.

Fixes #1376.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
authored and GitHub committed 2026-06-16 16:21:26 +08:00
1 parent ac037338cb
commit cf2d4f1b81
3 files changed
+322 -9

No files matched your search

+22 -1
View File
@@ -59,6 +59,15 @@ type Agent struct {
proxyLocalURL string // local URL of the proxy
platformPrompt string // platform-specific formatting instructions
// ccDataDir is injected by the cc-connect host (see buildAgentOptions
// in cmd/cc-connect/main.go). It locates the global directory where
// we write the shared cc-connect system prompt file (issue #1376
// workaround for Windows 8192-byte cmdline limit). The file at
// <ccDataDir>/agent-prompts/cc-connect-system.md is written once per
// startup and shared across all sessions that don't need per-spawn
// customisation. Empty value falls back to os.TempDir.
ccDataDir string
// spawnOpts controls OS-user isolation via run_as_user. Zero value
// means legacy spawn as the supervisor user. See core/runas.go.
spawnOpts core.SpawnOptions
@@ -138,6 +147,7 @@ func New(opts map[string]any) (core.Agent, error) {
mode = normalizePermissionMode(mode)
systemPrompt, _ := opts["system_prompt"].(string)
appendSystemPrompt, _ := opts["append_system_prompt"].(string)
ccDataDir, _ := opts["cc_data_dir"].(string)
var pluginDirs []string
if dir, ok := opts["plugin_dir"].(string); ok && dir != "" {
@@ -226,6 +236,16 @@ func New(opts map[string]any) (core.Agent, error) {
}
}
// Eagerly materialise the shared cc-connect-system.md at startup so
// the file exists on disk before the first spawn. claude reads it
// via --append-system-prompt-file; the lazy fallback in
// newClaudeSession still covers content drift (cc-connect upgrades)
// and the empty-ccDataDir corner case. Failure here is non-fatal —
// the next spawn will retry and surface the error then.
if _, err := ensureSharedSystemPromptFile(ccDataDir, core.AgentSystemPrompt()); err != nil {
slog.Warn("claudecode: failed to write shared system prompt file at startup; will retry on first spawn", "err", err, "cc_data_dir", ccDataDir)
}
return &Agent{
workDir: workDir,
cliBin: cliBin,
@@ -244,6 +264,7 @@ func New(opts map[string]any) (core.Agent, error) {
routerURL: routerURL,
routerAPIKey: routerAPIKey,
spawnOpts: spawnOpts,
ccDataDir: ccDataDir,
appendSystemPrompt: appendSystemPrompt,
}, nil
@@ -506,7 +527,7 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
disableVerbose := a.routerURL != ""
a.mu.Unlock()
return newClaudeSession(ctx, workDir, a.cliBin, a.cliExtraArgs, a.cliArgsFlag, model, effort, sessionID, mode, systemPrompt, appendSystemPrompt, tools, disTools, pluginDirs, extraEnv, platformPrompt, disableVerbose, a.spawnOpts, maxTok)
return newClaudeSession(ctx, workDir, a.cliBin, a.cliExtraArgs, a.cliArgsFlag, model, effort, sessionID, mode, systemPrompt, appendSystemPrompt, tools, disTools, pluginDirs, extraEnv, platformPrompt, disableVerbose, a.spawnOpts, maxTok, a.ccDataDir)
}
func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) {
+175 -8
View File
@@ -64,6 +64,14 @@ type claudeSession struct {
// startupWarning holds a one-time message to surface to the IM user at
// session start (e.g. when a permission mode was silently downgraded).
startupWarning string
// promptFilePath is the per-spawn temp file holding the merged
// content for --append-system-prompt-file when this session needs
// platform- or user-specific append text that the shared
// cc-connect-system.md cannot represent. Removed on Close. Empty
// when the session reuses the shared file (the common 99% case)
// or when there is nothing to append.
promptFilePath string
}
// StartupWarning implements core.StartupWarner. Returns a non-empty string
@@ -71,11 +79,117 @@ type claudeSession struct {
// know about (e.g. bypassPermissions downgraded to auto under root).
func (cs *claudeSession) StartupWarning() string { return cs.startupWarning }
// sharedSystemPromptRelPath is the location under ccDataDir where the
// shared cc-connect system prompt file lives. Reused across every spawn
// that doesn't need per-session customization (the 99% case).
const sharedSystemPromptRelPath = "agent-prompts/cc-connect-system.md"
// ensureSharedSystemPromptFile lazily writes <ccDataDir>/agent-prompts/
// cc-connect-system.md with the cc-connect default AgentSystemPrompt
// content, returning the path. The file is the workaround for the
// Windows 8192-byte command-line limit (issue #1376): cc-connect's
// built-in prompt is ~9KB on its own, so passing it inline via
// --append-system-prompt blows past the cap regardless of whether the
// user configured any customization.
//
// The file is only (re)written when missing or when its content differs
// from the current AgentSystemPrompt() — this lets cc-connect upgrades
// refresh the prompt automatically without per-spawn overhead. claude
// only reads the file at startup and never writes it, so there is no
// concurrent-write race even when multiple sessions spawn at once.
//
// If ccDataDir is empty (unlikely in production, but possible in tests),
// the file lands in os.TempDir.
func ensureSharedSystemPromptFile(ccDataDir, content string) (string, error) {
base := ccDataDir
if base == "" {
base = os.TempDir()
}
dir := filepath.Join(base, "agent-prompts")
path := filepath.Join(base, sharedSystemPromptRelPath)
if existing, err := os.ReadFile(path); err == nil && string(existing) == content {
return path, nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
return "", err
}
return path, nil
}
// writeTempAppendPromptFile writes the merged prompt content to a
// per-spawn temp file under ccDataDir/agent-prompts/, returning the
// path. Used only when the prompt has session-specific bits (platform
// FormattingInstructions or user-configured append_system_prompt) that
// the shared file cannot represent. A unique name from CreateTemp
// avoids two concurrent customised sessions overwriting each other.
//
// The caller is responsible for removing the file on session Close.
func writeTempAppendPromptFile(ccDataDir, content string) (string, error) {
base := ccDataDir
if base == "" {
base = os.TempDir()
}
dir := filepath.Join(base, "agent-prompts")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
f, err := os.CreateTemp(dir, "cc-connect-system-*.md")
if err != nil {
return "", err
}
if _, err := f.WriteString(content); err != nil {
_ = f.Close()
_ = os.Remove(f.Name())
return "", err
}
if err := f.Close(); err != nil {
_ = os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
// writeFileAtomic writes data to path via a temp file + rename, so a
// crash mid-write does not leave a half-written prompt file that the
// next spawn would mistake for valid content.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, ".cc-connect-system-*.tmp")
if err != nil {
return err
}
tmp := f.Name()
if _, err := f.Write(data); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Chmod(perm); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
// buildAppendSystemPrompt concatenates the cc-connect functionality prompt,
// platform formatting instructions, and the user's custom append prompt into
// the single string passed to Claude's --append-system-prompt flag. That flag
// only honors its last occurrence (a second flag overwrites the first), so all
// appended content must be merged here. Returns "" when nothing is to append.
// the single string passed to Claude's --append-system-prompt-file flag.
// That flag only honors its last occurrence (a second flag overwrites the
// first), so all appended content must be merged here. Returns "" when
// nothing is to append.
func buildAppendSystemPrompt(agentPrompt, platformPrompt, userAppend string) string {
var parts []string
if agentPrompt != "" {
@@ -90,7 +204,7 @@ func buildAppendSystemPrompt(agentPrompt, platformPrompt, userAppend string) str
return strings.Join(parts, "\n")
}
func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs []string, cliArgsFlag string, model, effort, sessionID, mode, systemPrompt, appendSystemPrompt string, allowedTools, disallowedTools []string, pluginDirs []string, extraEnv []string, platformPrompt string, disableVerbose bool, spawnOpts core.SpawnOptions, maxContextTokens int) (*claudeSession, error) {
func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs []string, cliArgsFlag string, model, effort, sessionID, mode, systemPrompt, appendSystemPrompt string, allowedTools, disallowedTools []string, pluginDirs []string, extraEnv []string, platformPrompt string, disableVerbose bool, spawnOpts core.SpawnOptions, maxContextTokens int, ccDataDir string) (*claudeSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
// Claude Code rejects bypassPermissions when running as root.
@@ -144,11 +258,42 @@ func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs
}
// Append the cc-connect functionality prompt, platform formatting hints,
// and the user's custom append prompt — all as a single flag. Claude's
// --append-system-prompt only honors its last occurrence, so the pieces
// must be concatenated here rather than passed as repeated flags.
// and the user's custom append prompt — via Claude's
// --append-system-prompt-file flag (not --append-system-prompt). Writing
// to a file avoids the Windows 8192-byte command-line limit (#1376):
// AgentSystemPrompt is ~9KB on its own and grew past the cap in v1.3.3,
// so even users with no customization need this workaround.
//
// Two paths exist to keep the common case zero-overhead:
// • 99% case (no platform formatting, no user append) — reuse the
// shared cc-connect-system.md file written once at startup; no
// per-spawn write, no cleanup needed.
// • 1% edge case (Slack/Weixin/MAX platform formatting or user-set
// append_system_prompt) — write a per-spawn temp file containing
// the merged content, removed on Close.
//
// Claude only reads the file at startup and never writes it, so the
// shared file is safe under concurrent spawns.
var promptFilePath string
var promptFileIsShared bool
if appended := buildAppendSystemPrompt(core.AgentSystemPrompt(), platformPrompt, appendSystemPrompt); appended != "" {
innerArgs = append(innerArgs, "--append-system-prompt", appended)
if platformPrompt == "" && appendSystemPrompt == "" {
path, err := ensureSharedSystemPromptFile(ccDataDir, appended)
if err != nil {
cancel()
return nil, fmt.Errorf("claudeSession: ensure shared prompt file: %w", err)
}
promptFilePath = path
promptFileIsShared = true
} else {
path, err := writeTempAppendPromptFile(ccDataDir, appended)
if err != nil {
cancel()
return nil, fmt.Errorf("claudeSession: write per-spawn prompt file: %w", err)
}
promptFilePath = path
}
innerArgs = append(innerArgs, "--append-system-prompt-file", promptFilePath)
}
if effort != "" {
@@ -269,10 +414,22 @@ func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
if promptFilePath != "" && !promptFileIsShared {
_ = os.Remove(promptFilePath)
}
cancel()
return nil, fmt.Errorf("claudeSession: start: %w", err)
}
// Only remember the prompt path for cleanup when it is the per-spawn
// temp variant. The shared cc-connect-system.md file is reused across
// all sessions and must never be deleted by an individual session's
// Close.
var cleanupPromptPath string
if !promptFileIsShared {
cleanupPromptPath = promptFilePath
}
cs := &claudeSession{
cmd: cmd,
stdin: stdin,
@@ -284,6 +441,7 @@ func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs
gracefulStopTimeout: 120 * time.Second,
ccHooks: newCCPermissionHookRunner(workDir),
startupWarning: rootDowngradeWarning,
promptFilePath: cleanupPromptPath,
}
cs.setPermissionMode(mode)
cs.sessionID.Store(sessionID)
@@ -969,6 +1127,15 @@ func (cs *claudeSession) Alive() bool {
}
func (cs *claudeSession) Close() error {
// Best-effort cleanup of the --append-system-prompt-file temp file on
// every exit path. The file is small (~9KB) and OS temp cleanup also
// eventually claims it, but explicit removal keeps workdirs tidy.
defer func() {
if cs.promptFilePath != "" {
_ = os.Remove(cs.promptFilePath)
}
}()
// Phase 1: Close stdin to signal EOF. Claude Code exits cleanly on
// stdin close, running Stop hooks (e.g. claude-mem session summary).
cs.stdinMu.Lock()
+125
View File
@@ -6,6 +6,8 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
@@ -457,6 +459,129 @@ func TestBuildAppendSystemPrompt(t *testing.T) {
}
}
// TestEnsureSharedSystemPromptFile_WritesOnceAndReuses covers the 99%
// case for the #1376 workaround. The cc-connect default
// AgentSystemPrompt is written once to <ccDataDir>/agent-prompts/
// cc-connect-system.md and reused across spawns — no per-spawn write,
// no cleanup. claude only reads the file, so reuse is safe under
// concurrent spawns.
func TestEnsureSharedSystemPromptFile_WritesOnceAndReuses(t *testing.T) {
dir := t.TempDir()
content := "## cc-connect prompt\n" + makeFiller(10*1024)
// First call must create the file.
path1, err := ensureSharedSystemPromptFile(dir, content)
if err != nil {
t.Fatalf("first ensure: %v", err)
}
if !strings.HasSuffix(filepath.ToSlash(path1), "agent-prompts/cc-connect-system.md") {
t.Errorf("path %q does not end in agent-prompts/cc-connect-system.md", path1)
}
got, err := os.ReadFile(path1)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(got) != content {
t.Fatalf("content mismatch after first write")
}
stat1, err := os.Stat(path1)
if err != nil {
t.Fatalf("stat: %v", err)
}
// Second call with identical content must NOT rewrite the file
// (mtime stays the same). This is what gives the common case
// zero per-spawn overhead.
time.Sleep(20 * time.Millisecond)
path2, err := ensureSharedSystemPromptFile(dir, content)
if err != nil {
t.Fatalf("second ensure: %v", err)
}
if path2 != path1 {
t.Errorf("path drifted between calls: %q vs %q", path1, path2)
}
stat2, err := os.Stat(path2)
if err != nil {
t.Fatalf("stat 2: %v", err)
}
if !stat1.ModTime().Equal(stat2.ModTime()) {
t.Errorf("file was rewritten despite identical content: mtime %v -> %v",
stat1.ModTime(), stat2.ModTime())
}
}
// TestEnsureSharedSystemPromptFile_RewritesOnContentChange covers
// cc-connect upgrades: when AgentSystemPrompt content changes between
// releases, the shared file must be refreshed automatically.
func TestEnsureSharedSystemPromptFile_RewritesOnContentChange(t *testing.T) {
dir := t.TempDir()
if _, err := ensureSharedSystemPromptFile(dir, "v1"); err != nil {
t.Fatalf("ensure v1: %v", err)
}
path, err := ensureSharedSystemPromptFile(dir, "v2 — upgraded prompt")
if err != nil {
t.Fatalf("ensure v2: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(got) != "v2 — upgraded prompt" {
t.Fatalf("file did not refresh after content change: got %q", string(got))
}
}
// TestEnsureSharedSystemPromptFile_EmptyDirUsesTempDir guards the
// degraded path where ccDataDir was not injected (e.g. older host
// code or test harnesses) — the shared file still lands somewhere
// writable instead of failing the spawn.
func TestEnsureSharedSystemPromptFile_EmptyDirUsesTempDir(t *testing.T) {
path, err := ensureSharedSystemPromptFile("", "hello")
if err != nil {
t.Fatalf("ensure with empty dir: %v", err)
}
t.Cleanup(func() { _ = os.Remove(path) })
if !strings.Contains(filepath.ToSlash(path), "/agent-prompts/cc-connect-system.md") {
t.Errorf("unexpected fallback path: %q", path)
}
}
// TestWriteTempAppendPromptFile_UniquePerCall covers the 1% edge case:
// when the prompt includes per-session pieces (platform formatting or
// user append) two concurrent spawns must each get their own file so
// they cannot overwrite each other's content before claude reads it.
func TestWriteTempAppendPromptFile_UniquePerCall(t *testing.T) {
// dir is auto-cleaned by t.TempDir(), so per-file Remove is unnecessary.
dir := t.TempDir()
a, err := writeTempAppendPromptFile(dir, "session A")
if err != nil {
t.Fatalf("write A: %v", err)
}
b, err := writeTempAppendPromptFile(dir, "session B")
if err != nil {
t.Fatalf("write B: %v", err)
}
if a == b {
t.Fatalf("two writeTempAppendPromptFile calls returned the same path %q "+
"— concurrent customised sessions would overwrite each other", a)
}
// Files must contain their own content (no cross-talk).
gotA, _ := os.ReadFile(a)
gotB, _ := os.ReadFile(b)
if string(gotA) != "session A" || string(gotB) != "session B" {
t.Errorf("cross-talk: A=%q B=%q", string(gotA), string(gotB))
}
}
func makeFiller(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = 'a' + byte(i%26)
}
return string(b)
}
func helperCommand(ctx context.Context, mode string) *exec.Cmd {
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestHelperProcess", "--", mode)
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")