Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion findcc.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,10 +279,24 @@ export function resolveNpmClaudePath() {
const match = normReal.match(/(.*node_modules\/@[^/]+\/[^/]+)\//);
if (match) {
const packageDir = match[1];
// Claude Code 1.x: cli.js (injected with interceptor)
const cliPath = join(packageDir, CLI_ENTRY);
if (existsSync(cliPath)) {
return cliPath;
}
// Claude Code 2.x: no cli.js — the npm binary itself is the entry point.
// The native platform binary (claude-code-darwin-arm64/claude et al.) crashes
// with SIGKILL when passed --settings; the npm binary works correctly.
const npmBinDir = join(packageDir, 'bin');
const binCandidates = process.platform === 'win32'
? ['claude.exe', 'claude.cmd']
: ['claude.exe', 'claude'];
for (const name of binCandidates) {
const binPath = join(npmBinDir, name);
if (existsSync(binPath)) {
return binPath;
}
}
}
}
} catch { }
Expand All @@ -300,10 +314,23 @@ export function resolveNpmClaudePath() {
const globalRoot = getGlobalNodeModulesDir();
if (globalRoot) {
for (const packageName of PACKAGES) {
const cliPath = join(globalRoot, packageName, CLI_ENTRY);
const pkgDir = join(globalRoot, packageName);
// Claude Code 1.x: cli.js
const cliPath = join(pkgDir, CLI_ENTRY);
if (existsSync(cliPath)) {
return cliPath;
}
// Claude Code 2.x: no cli.js, fall back to the npm binary in bin/
const binDir = join(pkgDir, 'bin');
const binCandidates = process.platform === 'win32'
? ['claude.exe', 'claude.cmd']
: ['claude.exe', 'claude'];
for (const name of binCandidates) {
const binPath = join(binDir, name);
if (existsSync(binPath)) {
return binPath;
}
}
}
}

Expand Down
13 changes: 8 additions & 5 deletions server/lib/create_system_prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// node server/lib/create_system_prompt.js deepseek-v4-pro # a named preset
// node server/lib/create_system_prompt.js --list # list presets

import { execFileSync } from 'node:child_process'
import { spawnSync } from 'node:child_process'
import { readFileSync, statSync } from 'node:fs'
import os from 'node:os'
import { delimiter, join, sep } from 'node:path'
Expand Down Expand Up @@ -54,13 +54,16 @@ function envString(name) {
}

function commandOutput(command, args, cwd) {
return stringOrEmpty(() =>
execFileSync(command, args, {
return stringOrEmpty(() => {
const result = spawnSync(command, args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim(),
)
timeout: 15000, // 15s hard cap: hanging git (NFS / huge repo / broken index) must not block spawn
});
if (result.error || result.status !== 0) return '';
return result.stdout.trim();
});
}

function firstNonEmpty(...values) {
Expand Down
6 changes: 5 additions & 1 deletion server/lib/system-prompt-render.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ export function renderedPromptDir(pid = process.pid) {
* whose content was rendered point at the temp copy instead.
*/
export function renderSystemPromptFileArgs(sysPrompt, opts = {}) {
const args = Array.isArray(sysPrompt?.args) ? sysPrompt.args : [];
// Defensive: null / non-object / missing args → pass through untouched, so the
// caller always gets a safe spread target and the "loaded" notice guard
// (sysPrompt.loaded.length) doesn't throw.
if (!sysPrompt || typeof sysPrompt !== 'object') return { args: [], loaded: [], model: null };
const args = Array.isArray(sysPrompt.args) ? sysPrompt.args : [];
if (args.length === 0) return sysPrompt;

let variables = null; // lazy: collected once, and only if some file actually has placeholders
Expand Down
72 changes: 45 additions & 27 deletions server/pty-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,28 +299,39 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
// 注:currentWorkspacePath 在下方才赋值,这里用 cwd 参数判定启动目录。
// LOG_DIR 内的 spawn(IM worker 工作目录 = <LOG_DIR>/IM_<id>/)跳过模型匹配:
// IM 人格依赖默认 sentinel CC_APPEND_SYSTEM.md 注入,全局模型条目不得静默取代它。
const spawnDir = cwd || process.cwd();
const insideLogDir = spawnDir === LOG_DIR || spawnDir.startsWith(LOG_DIR + sep);
const resolvedModelId = insideLogDir ? null : _spawnModelReader(spawnDir);
let sysPrompt = buildSystemPromptFileArgs(spawnDir, finalExtraArgs, process.env, {
modelId: resolvedModelId,
globalModelDir: join(LOG_DIR, MODEL_PROMPT_DIR),
});
if (_systemPromptFileRejectedPaths.has(claudePath)) {
//
// 整个 system prompt 构建 + 渲染管道包在 try-catch 里:任何意外抛错(含 readClaudeProjectModel
// JSON 解析、buildSystemPromptFileArgs 文件系统竞态、renderSystemPromptFileArgs 的
// createSystemPromptVariables git 子进程异常)都走兜底——当没命中任何条目,launch 不带
// --system-prompt-file/--append-system-prompt-file,claude 用自身默认 system prompt 启动。
let sysPrompt;
try {
const spawnDir = cwd || process.cwd();
const insideLogDir = spawnDir === LOG_DIR || spawnDir.startsWith(LOG_DIR + sep);
const resolvedModelId = insideLogDir ? null : _spawnModelReader(spawnDir);
sysPrompt = buildSystemPromptFileArgs(spawnDir, finalExtraArgs, process.env, {
modelId: resolvedModelId,
globalModelDir: join(LOG_DIR, MODEL_PROMPT_DIR),
});
if (_systemPromptFileRejectedPaths.has(claudePath)) {
sysPrompt = { args: [], loaded: [], model: null };
} else if (resolvedModelId && !sysPrompt.model && !sysPrompt.suppressed
&& (existsSync(join(spawnDir, MODEL_PROMPT_DIR)) || existsSync(join(LOG_DIR, MODEL_PROMPT_DIR)))) {
// The one diagnostic case worth a warning: a system_prompt dir is configured
// but the resolved model matched no entry (likely a misnamed file). Intentional
// skips (CCV_DISABLE_AUTO_SYSTEM_PROMPT=1, or a manual --system-prompt flag
// suppressing a matched entry) carry `suppressed` and stay quiet. The
// successful-injection notice is emitted below via emitSpawnNotice (with
// internal-restart suppression); no-modelId spawns are the normal quiet path.
console.warn(`[CC Viewer] model-specific prompt: modelId="${resolvedModelId}" resolved but no matching entry found in workspace or global ${MODEL_PROMPT_DIR}/`);
}
// Resolve `${...}` template variables in the injected files (editor stores them literal —
// the substitution documented by the editor's parameter reference happens here, at launch).
sysPrompt = renderSystemPromptFileArgs(sysPrompt, { cwd: spawnDir, modelId: resolvedModelId });
} catch (err) {
console.warn(`[CC Viewer] system prompt build/render failed, launching without injected prompt:`, err?.message || err);
sysPrompt = { args: [], loaded: [], model: null };
} else if (resolvedModelId && !sysPrompt.model && !sysPrompt.suppressed
&& (existsSync(join(spawnDir, MODEL_PROMPT_DIR)) || existsSync(join(LOG_DIR, MODEL_PROMPT_DIR)))) {
// The one diagnostic case worth a warning: a system_prompt dir is configured
// but the resolved model matched no entry (likely a misnamed file). Intentional
// skips (CCV_DISABLE_AUTO_SYSTEM_PROMPT=1, or a manual --system-prompt flag
// suppressing a matched entry) carry `suppressed` and stay quiet. The
// successful-injection notice is emitted below via emitSpawnNotice (with
// internal-restart suppression); no-modelId spawns are the normal quiet path.
console.warn(`[CC Viewer] model-specific prompt: modelId="${resolvedModelId}" resolved but no matching entry found in workspace or global ${MODEL_PROMPT_DIR}/`);
}
// Resolve `${...}` template variables in the injected files (editor stores them literal —
// the substitution documented by the editor's parameter reference happens here, at launch).
sysPrompt = renderSystemPromptFileArgs(sysPrompt, { cwd: spawnDir, modelId: resolvedModelId });
const launchArgs = sysPrompt.args.length ? [...finalExtraArgs, ...sysPrompt.args] : finalExtraArgs;

let command = claudePath;
Expand Down Expand Up @@ -348,13 +359,11 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
// --allow-dangerously-skip-permissions only enables a later toggle, so it must NOT count.
ptySkipPermissions = extraArgs.includes('--dangerously-skip-permissions');

// 注入了 system prompt 文件时向终端打印一行提示(可见性/安全);内部重启已抑制以免重复。
if (sysPrompt.loaded.length && !_suppressNextSpawnNotice) {
const modelSuffix = sysPrompt.model ? ` (model match: ${sysPrompt.model})` : '';
emitSpawnNotice(`[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
}
_suppressNextSpawnNotice = false;

// Register PTY event handlers IMMEDIATELY after spawn, before any other
// synchronous work. If the child process exits before onExit is registered
// (e.g. the binary is missing, crashes instantly, or rejects an injected
// --system-prompt-file flag), the exit event is lost — the handle releases,
// and without a live PTY the event loop may drain despite the HTTP servers.
ptyProcess.onData((data) => {
outputBuffer += data;
if (outputBuffer.length > MAX_BUFFER) {
Expand Down Expand Up @@ -424,6 +433,15 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
}
});

// Notice must fire AFTER onData/onExit are registered: if the child exits before onExit
// is attached, the exit event is lost and the process handle releases, which can drain
// the event loop (the HTTP servers alone may not always prevent exit on all platforms).
if (sysPrompt.loaded.length && !_suppressNextSpawnNotice) {
const modelSuffix = sysPrompt.model ? ` (model match: ${sysPrompt.model})` : '';
emitSpawnNotice(`[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
}
_suppressNextSpawnNotice = false;

return ptyProcess;
}

Expand Down
13 changes: 12 additions & 1 deletion server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -968,9 +968,15 @@ export async function startViewer() {
currentServer = createServer(handleRequest);
}

currentServer.listen(port, HOST, async () => {
currentServer.listen(port, HOST, () => {
server = currentServer;
actualPort = port;
// Wrap the entire async setup in a fire-and-forget try-catch so that any
// unhandled rejection (e.g. setupTerminalWebSocket / runParallelHook
// / imCore.startBridge throwing) cannot crash the process after the port
// is already bound and cli.js has proceeded to spawnClaude.
(async () => {
try {
// 把服务端 i18n 的 currentLang 同步成用户在 UI 配置的语言(preferences.lang)。
// 否则服务端 t() 恒为默认 'zh'——DingTalk 桥接的系统提示、登录页回落语言都不跟随配置。
// setLang 自带 locale 校验,非法/缺失值回落 en,读 prefs 失败也安全跳过。
Expand Down Expand Up @@ -1118,6 +1124,11 @@ export async function startViewer() {
imProcMgr.reconcileImProcesses().catch((e) => console.error('[CC Viewer] IM reconcile failed:', e?.message || e));
}
resolve(server);
} catch (err) {
console.error('[CC Viewer] server start callback error:', err?.message || err);
try { resolve(server); } catch {}
}
})();
});

currentServer.on('error', (err) => {
Expand Down
Loading