cc-viewer 1.6.345 → 1.6.348

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ // Spawn-time model resolution from the CURRENTLY EFFECTIVE configuration.
2
+ //
3
+ // Replaces the old injection criterion for model-specific system prompts, which read
4
+ // `projects[cwd].lastModelUsage` from ~/.claude.json — a usage STATISTIC of the previous
5
+ // session, not configuration. A stale record (e.g. a past deepseek experiment) made ccv
6
+ // inject a third-party override prompt into an official-model session. The new criterion
7
+ // only trusts live configuration signals; with no signal it returns null and no model
8
+ // entry is injected (the CC_SYSTEM.md / CC_APPEND_SYSTEM.md sentinels are unaffected).
9
+ //
10
+ // Signal priority (best-effort heuristic — the shell-env vs settings.json-env ordering is
11
+ // a repo convention, not documented Claude Code semantics; a mismatch merely means "no
12
+ // injection" or "same-family entry", both harmless):
13
+ // 1. active third-party proxy profile for the spawned workspace (family mapping via
14
+ // resolveProfileModel — the model the ccv proxy will actually route to)
15
+ // 2. env CLAUDE_MODEL > env ANTHROPIC_MODEL (shell exports inherited by the spawn)
16
+ // 3. settings.json `env.ANTHROPIC_MODEL` > settings.json top-level `model`
17
+ // (the env block outranks the top-level field, mirroring how Claude Code applies it)
18
+ // Known limitation: a `--model` flag passed through extraArgs is not consulted.
19
+ //
20
+ // Pure disk reads — deliberately NOT importing server/interceptor.js: its module-level
21
+ // side effects (log init, watchFile) must not leak into pty-manager unit tests, and its
22
+ // live `_activeProfile` reflects the ccv process's CURRENT workspace, which is not
23
+ // necessarily the workspace being spawned (multi-workspace/tab mode). All paths are
24
+ // injectable for tests; the defaults derive from LOG_DIR / getClaudeConfigDir(), both
25
+ // covered by the L1b/L1c/L1d test-isolation barriers.
26
+ import { readFileSync } from 'node:fs';
27
+ import { join, basename } from 'node:path';
28
+ import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
29
+ import { resolveProfileModel } from './interceptor-core.js';
30
+
31
+ function readJson(path) {
32
+ try {
33
+ return JSON.parse(readFileSync(path, 'utf-8'));
34
+ } catch {
35
+ return null; // missing / unreadable / corrupt → treated as "no signal"
36
+ }
37
+ }
38
+
39
+ function nonEmpty(v) {
40
+ return (typeof v === 'string' && v.trim()) ? v.trim() : null;
41
+ }
42
+
43
+ // `model: "default"` means "let Claude Code pick" — it names no concrete model/family,
44
+ // so it must not participate in entry matching (an entry literally named DEFAULT is
45
+ // rejected by the entry-name grammar anyway). Other aliases (opus/sonnet/opusplan/…)
46
+ // stay verbatim: they express an explicit family intent and matching a same-family
47
+ // entry is the desired behavior.
48
+ function normalizeAlias(model) {
49
+ if (!model) return null;
50
+ return model.toLowerCase() === 'default' ? null : model;
51
+ }
52
+
53
+ /**
54
+ * The active THIRD-PARTY proxy profile for the workspace being spawned, or null.
55
+ * Resolution mirrors interceptor.js `_loadProxyProfile`: the workspace-scoped
56
+ * `<logDir>/<project>/active-profile.json` activeId outranks the global
57
+ * `profile.json` `active`; id 'max' is the built-in Default (= no third-party profile).
58
+ * The project-dir derivation is character-identical to interceptor.js:353.
59
+ */
60
+ function readActiveProfile(spawnDir, logDir) {
61
+ const data = readJson(join(logDir, 'profile.json'));
62
+ if (!data || !Array.isArray(data.profiles)) return null;
63
+ let activeId = null;
64
+ if (spawnDir) {
65
+ const projectName = basename(spawnDir).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
66
+ const ws = readJson(join(logDir, projectName, 'active-profile.json'));
67
+ if (ws && typeof ws.activeId === 'string') activeId = ws.activeId;
68
+ }
69
+ if (!activeId) activeId = typeof data.active === 'string' ? data.active : null;
70
+ if (!activeId || activeId === 'max') return null;
71
+ const profile = data.profiles.find((p) => p && p.id === activeId);
72
+ return (profile && profile.id !== 'max') ? profile : null;
73
+ }
74
+
75
+ /**
76
+ * Resolve the model id the spawned claude session will effectively use, from live
77
+ * configuration only. Returns null when no configuration signal exists — the caller
78
+ * must then skip model-entry injection.
79
+ *
80
+ * @param {string} spawnDir workspace being launched (absolute path)
81
+ * @param {Object} [env] environment (default process.env)
82
+ * @param {{ logDir?: string, configDir?: string }} [opts] test seams for the data roots
83
+ * @returns {string|null}
84
+ */
85
+ export function resolveSpawnModel(spawnDir, env = process.env, opts = {}) {
86
+ const logDir = opts.logDir || LOG_DIR;
87
+ const configDir = opts.configDir || getClaudeConfigDir();
88
+
89
+ // Base model: what claude itself is configured to request.
90
+ let base = nonEmpty(env?.CLAUDE_MODEL) || nonEmpty(env?.ANTHROPIC_MODEL);
91
+ if (!base) {
92
+ const settings = readJson(join(configDir, 'settings.json'));
93
+ base = nonEmpty(settings?.env?.ANTHROPIC_MODEL) || nonEmpty(settings?.model);
94
+ }
95
+ base = normalizeAlias(base);
96
+
97
+ // Active third-party profile: the proxy rewrites requests per model family, so the
98
+ // effective model is the mapping target; an unmapped family passes through as base.
99
+ const profile = readActiveProfile(spawnDir, logDir);
100
+ if (profile) {
101
+ const mapped = base ? resolveProfileModel(base, profile) : null;
102
+ return nonEmpty(mapped)
103
+ || base
104
+ || nonEmpty(profile.ANTHROPIC_MODEL)
105
+ || nonEmpty(profile.activeModel) // legacy whole-replacement profiles
106
+ || null;
107
+ }
108
+ return base;
109
+ }
@@ -53,13 +53,15 @@ function hasArg(args, ...names) {
53
53
  * @param {string[]} [existingArgs] 已有的 claude 参数(用于「手动优先」判断)
54
54
  * @param {Object} [env] 环境变量(默认 process.env)
55
55
  * @param {{ modelId?: string|null, globalModelDir?: string|null }} [opts]
56
- * @returns {{ args: string[], loaded: string[], model: string|null }}
57
- * args: 待追加参数;loaded: 实际加载的文件(终端提示);model: 命中的条目名(未命中为 null)
56
+ * @returns {{ args: string[], loaded: string[], model: string|null, suppressed?: 'env'|'manual-flag' }}
57
+ * args: 待追加参数;loaded: 实际加载的文件(终端提示);model: 命中的条目名(未命中为 null)
58
+ * suppressed: 注入被有意跳过的原因(env 开关 / 手动同义 flag 抑制了已命中的模型条目)——
59
+ * 调用方(pty-manager)据此不再打「no matching entry」误导性告警。
58
60
  */
59
61
  export function buildSystemPromptFileArgs(projectDir, existingArgs = [], env = process.env, opts = {}) {
60
62
  const out = { args: [], loaded: [], model: null };
61
63
  if (!projectDir) return out;
62
- if (env?.[DISABLE_AUTO_SYSTEM_PROMPT_ENV] === '1') return out;
64
+ if (env?.[DISABLE_AUTO_SYSTEM_PROMPT_ENV] === '1') return { ...out, suppressed: 'env' };
63
65
 
64
66
  if (opts?.modelId) {
65
67
  const candidates = [{ dir: join(projectDir, MODEL_PROMPT_DIR), scope: 'workspace' }];
@@ -73,6 +75,8 @@ export function buildSystemPromptFileArgs(projectDir, existingArgs = [], env = p
73
75
  out.args.push(flagPair[1], match.path);
74
76
  out.loaded.push(`${match.scope === 'global' ? 'global ' : ''}${MODEL_PROMPT_DIR}/${match.fileName}`);
75
77
  out.model = match.name;
78
+ } else {
79
+ out.suppressed = 'manual-flag'; // 条目命中但用户手传了同义 flag:有意跳过,非「无条目」
76
80
  }
77
81
  return out; // 命中即返回:默认 sentinel 不再参与(含手动 flag 抑制注入的情况)。
78
82
  }
@@ -0,0 +1,79 @@
1
+ // Spawn-time `${...}` template-variable rendering for injected system-prompt files.
2
+ //
3
+ // The Edit System Prompt editor stores entries (model entries under system_prompt/ and the
4
+ // CC_SYSTEM.md / CC_APPEND_SYSTEM.md sentinels) with `${...}` placeholders LITERAL — that is the
5
+ // editing surface documented by the "Dynamic Parameter Documentation" popup. The substitution has
6
+ // to happen when claude is launched: this module takes the args produced by
7
+ // buildSystemPromptFileArgs, and for every injected file whose content contains template
8
+ // variables, renders it via create_system_prompt.js and swaps in a rendered temp copy.
9
+ //
10
+ // Design constraints:
11
+ // - Files without `${...}` pass through untouched (zero cost — no variable collection, which
12
+ // shells out to git several times, and no temp file).
13
+ // - missingVariableMode is 'keep': unknown placeholders stay literal, so user-authored prompt
14
+ // text quoting shell syntax like `${HOME}` or `${1:-default}` is never eaten.
15
+ // - Any failure (unreadable file, variable collection throwing, temp write failing) falls back
16
+ // to the raw path — rendering must never break the claude spawn.
17
+ // - Temp copies live under <tmpdir>/cc-viewer-rendered-prompts/<pid>/: per-process so two ccv
18
+ // instances never clobber each other; sequential spawns of one instance overwrite the same
19
+ // basename, which is safe because claude reads the file once at startup.
20
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
21
+ import { join, basename } from 'node:path';
22
+ import { tmpdir } from 'node:os';
23
+ import { createSystemPrompt, createSystemPromptVariables } from './create_system_prompt.js';
24
+
25
+ // Detection only (no capture): does the text contain at least one `${...}` placeholder?
26
+ const TEMPLATE_VARIABLE_RE = /\$\{[^}]+\}/;
27
+
28
+ /** Per-process directory holding the rendered temp copies. */
29
+ export function renderedPromptDir(pid = process.pid) {
30
+ return join(tmpdir(), 'cc-viewer-rendered-prompts', String(pid));
31
+ }
32
+
33
+ /**
34
+ * Render template variables in the file args produced by buildSystemPromptFileArgs.
35
+ *
36
+ * @param {{ args: string[], loaded: string[], model: string|null }} sysPrompt
37
+ * @param {{ cwd?: string, modelId?: string|null, variablesFactory?: Function }} [opts]
38
+ * cwd: workspace being launched (git/cwd/memory variables resolve against it);
39
+ * modelId: resolved model id from the last launch — becomes ${model.name} (the [1m]
40
+ * context-window suffix is stripped: it is a claude-code UI notation, not a model name);
41
+ * variablesFactory: test seam replacing createSystemPromptVariables.
42
+ * @returns {{ args: string[], loaded: string[], model: string|null }} same shape; file paths
43
+ * whose content was rendered point at the temp copy instead.
44
+ */
45
+ export function renderSystemPromptFileArgs(sysPrompt, opts = {}) {
46
+ // Defensive: null / non-object → safe default, so the caller always gets a valid
47
+ // spread target and the "loaded" notice guard (sysPrompt.loaded.length) can't throw.
48
+ if (!sysPrompt || typeof sysPrompt !== 'object') return { args: [], loaded: [], model: null };
49
+ const args = Array.isArray(sysPrompt.args) ? sysPrompt.args : [];
50
+ if (args.length === 0) return sysPrompt;
51
+
52
+ let variables = null; // lazy: collected once, and only if some file actually has placeholders
53
+ const out = [...args];
54
+ for (let i = 0; i + 1 < out.length; i += 2) {
55
+ const flag = out[i];
56
+ if (flag !== '--system-prompt-file' && flag !== '--append-system-prompt-file') continue;
57
+ const path = out[i + 1];
58
+ try {
59
+ const text = readFileSync(path, 'utf-8');
60
+ if (!TEMPLATE_VARIABLE_RE.test(text)) continue;
61
+ if (!variables) {
62
+ const factory = opts.variablesFactory || createSystemPromptVariables;
63
+ const overrides = {};
64
+ if (opts.modelId) overrides.model = { name: String(opts.modelId).replace(/\[1m\]$/, '') };
65
+ variables = factory(overrides, { cwd: opts.cwd });
66
+ }
67
+ const rendered = createSystemPrompt(text, { variables, missingVariableMode: 'keep' });
68
+ if (rendered === text) continue; // every placeholder unknown → nothing changed, keep raw path
69
+ const dir = renderedPromptDir();
70
+ mkdirSync(dir, { recursive: true });
71
+ const target = join(dir, basename(path));
72
+ writeFileSync(target, rendered, 'utf-8');
73
+ out[i + 1] = target;
74
+ } catch (e) {
75
+ console.warn(`[CC Viewer] system-prompt template render failed for ${path} (injecting the raw file):`, e?.message || e);
76
+ }
77
+ }
78
+ return { ...sysPrompt, args: out };
79
+ }
@@ -8,8 +8,9 @@ import { prepareEmbeddedShellSpawn, stripClaudeNoFlickerUnlessOptedIn, applyClau
8
8
  import { killPtyTree } from './lib/term-signals.js';
9
9
  import { findSafeSliceStart, splitTrailingIncomplete } from './lib/ansi-safe-slice.js';
10
10
  import { buildSystemPromptFileArgs } from './lib/system-prompt-files.js';
11
+ import { renderSystemPromptFileArgs } from './lib/system-prompt-render.js';
11
12
  import { MODEL_PROMPT_DIR } from './lib/model-system-prompts.js';
12
- import { readClaudeProjectModel } from './lib/context-watcher.js';
13
+ import { resolveSpawnModel } from './lib/spawn-model-resolver.js';
13
14
 
14
15
  const __filename = fileURLToPath(import.meta.url);
15
16
  const __dirname = dirname(__filename);
@@ -50,20 +51,31 @@ export function _setPtyImportForTests(fn) {
50
51
  _ptyImportForTests = fn;
51
52
  }
52
53
 
53
- // spawn 时读取「上次启动所用模型 id」供模型定制 system prompt 匹配。
54
- // readClaudeProjectModel 读的是真实 ~/.claude.json(无 NODE_TEST_CONTEXT 屏障),
55
- // 而多数 pty 单测以仓库根为 cwd —— 默认读取器在测试上下文里必须失活,
56
- // 否则开发机的真实模型记录会漏进单测(机器状态依赖)。测试用 _setSpawnModelReaderForTests 显式注入。
57
- // env/reader 参数化只为可测性:单测能直接断言「测试上下文 → null / 生产 → 透传」,
58
- // 而无需真的读 ~/.claude.json(见 test/pty-manager.test.js 的 guard 单测)。
59
- export function _defaultSpawnModelReader(c, env = process.env, reader = readClaudeProjectModel) {
60
- return env.NODE_TEST_CONTEXT ? null : reader(c);
54
+ // spawn 时解析「当前生效配置」下的模型 id 供模型定制 system prompt 匹配(resolveSpawnModel:
55
+ // 激活的三方 proxy profile 模型映射 > env CLAUDE_MODEL/ANTHROPIC_MODEL > settings.json
56
+ // 无实时配置信号 null 不注入模型条目)。旧判据读 ~/.claude.json 的 lastModelUsage
57
+ // ——那是上次会话的使用统计而非配置,陈旧记录会把三方模型的 override 提示词强加给
58
+ // 官方模型会话(review round:deepseek 残留记录事故)。
59
+ // NODE_TEST_CONTEXT 屏障保留:resolveSpawnModel 会读 process.env 的模型变量,开发机
60
+ // shell export 会漏进单测(机器状态依赖);测试用 _setSpawnModelReaderForTests 显式注入。
61
+ // env/reader 参数化只为可测性(见 test/pty-manager.test.js guard 单测)
62
+ export function _defaultSpawnModelReader(c, env = process.env, reader = resolveSpawnModel) {
63
+ return env.NODE_TEST_CONTEXT ? null : reader(c, env);
61
64
  }
62
65
  let _spawnModelReader = _defaultSpawnModelReader;
63
66
  export function _setSpawnModelReaderForTests(fn) {
64
67
  _spawnModelReader = fn || _defaultSpawnModelReader;
65
68
  }
66
69
 
70
+ // 启动兜底的时间源与窗口:spawn 后在窗口内死亡视为「引导期死亡」。真实引导崩溃 <1s,
71
+ // 5s 足够;更长的窗口只会扩大「用户快速主动退出」的误报面(评审取值)。
72
+ // _now 可注入:兜底用例需要拨表模拟「存活超窗后退出」。
73
+ const SYS_PROMPT_BOOT_WINDOW_MS = 5000;
74
+ let _now = Date.now;
75
+ export function _setNowForTests(fn) {
76
+ _now = fn || Date.now;
77
+ }
78
+
67
79
  async function getPty() {
68
80
  if (typeof _ptyImportForTests === 'function') {
69
81
  return _ptyImportForTests();
@@ -152,8 +164,15 @@ const _thinkingDisplayRejectedPaths = new Set();
152
164
  // 启动目录里的 CC_SYSTEM.md / CC_APPEND_SYSTEM.md 自动注入 --system-prompt-file/--append-system-prompt-file。
153
165
  // 若目标 claude(或三方 fork/wrapper)不识别该 flag,onExit 检测 "unknown option" 后把 claudePath 记入此集,
154
166
  // 下次 spawn 跳过注入并去 flag 重启(对齐 _thinkingDisplayRejectedPaths 自愈)。
167
+ // 语义:永久(进程级)——"unknown option" 是该二进制不支持 flag 的确定性能力信号。
155
168
  const _systemPromptFileRejectedPaths = new Set();
156
169
 
170
+ // 一次性跳过令牌:启动兜底一级的放宽半支(引导窗口内非信号 exit≠0)覆盖的是**瞬态**崩溃
171
+ // (API key 失效/网络抖动/与注入无关的秒退),不能写进上面的永久拒绝集——否则一次瞬态故障
172
+ // 会在整个 ccv 进程生命周期内静默禁用该二进制的注入(review P1)。令牌在下一次 spawn 被
173
+ // 消费(delete):恰好保证去注入重试一次,之后的 spawn 恢复正常注入尝试。
174
+ const _skipInjectionOncePaths = new Set();
175
+
157
176
  // 内部重启(-c 重试 / flag 自愈)时抑制一次注入提示,避免终端重复打印同一行。
158
177
  let _suppressNextSpawnNotice = false;
159
178
 
@@ -172,9 +191,10 @@ export function _markThinkingDisplayRejected(claudePath) {
172
191
  _thinkingDisplayRejectedPaths.add(claudePath);
173
192
  }
174
193
 
175
- // 仅用于测试/内部:清空 system-prompt-file 拒绝集
194
+ // 仅用于测试/内部:清空 system-prompt-file 拒绝集(连同一次性跳过令牌,保持用例间干净)
176
195
  export function _clearSystemPromptFileRejectedPaths() {
177
196
  _systemPromptFileRejectedPaths.clear();
197
+ _skipInjectionOncePaths.clear();
178
198
  }
179
199
 
180
200
  // 仅用于测试:查询路径是否已被标记为不支持 --system-prompt-file
@@ -299,21 +319,40 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
299
319
  // LOG_DIR 内的 spawn(IM worker 工作目录 = <LOG_DIR>/IM_<id>/)跳过模型匹配:
300
320
  // IM 人格依赖默认 sentinel CC_APPEND_SYSTEM.md 注入,全局模型条目不得静默取代它。
301
321
  const spawnDir = cwd || process.cwd();
322
+ // insideLogDir 留在 try 外:下方 onExit 的启动兜底门控也要用它。
302
323
  const insideLogDir = spawnDir === LOG_DIR || spawnDir.startsWith(LOG_DIR + sep);
303
- const resolvedModelId = insideLogDir ? null : _spawnModelReader(spawnDir);
304
- let sysPrompt = buildSystemPromptFileArgs(spawnDir, finalExtraArgs, process.env, {
305
- modelId: resolvedModelId,
306
- globalModelDir: join(LOG_DIR, MODEL_PROMPT_DIR),
307
- });
308
- if (_systemPromptFileRejectedPaths.has(claudePath)) {
324
+ // 整个 system prompt 构建 + 渲染管道包在 try-catch 里(PR#128):任何意外抛错(模型解析、
325
+ // buildSystemPromptFileArgs 文件系统竞态、渲染的 git 子进程异常)都走兜底——按「没命中任何
326
+ // 条目」处理,launch 不带 --system-prompt-file/--append-system-prompt-file,claude 用自身
327
+ // 默认 system prompt 启动。注入失败绝不能阻断 spawn。
328
+ let sysPrompt = { args: [], loaded: [], model: null };
329
+ // 一次性跳过令牌在进入管道前无条件消费(delete):放宽半支的兜底只跳过紧随其后的这一次注入。
330
+ // 若放在 build 之后,build 抛错时令牌残留、会再多跳过一次(违反 exactly-once,review)。
331
+ const skipOnce = _skipInjectionOncePaths.delete(claudePath);
332
+ try {
333
+ const resolvedModelId = insideLogDir ? null : _spawnModelReader(spawnDir);
334
+ sysPrompt = buildSystemPromptFileArgs(spawnDir, finalExtraArgs, process.env, {
335
+ modelId: resolvedModelId,
336
+ globalModelDir: join(LOG_DIR, MODEL_PROMPT_DIR),
337
+ });
338
+ if (_systemPromptFileRejectedPaths.has(claudePath) || skipOnce) {
339
+ sysPrompt = { args: [], loaded: [], model: null };
340
+ } else if (resolvedModelId && !sysPrompt.model && !sysPrompt.suppressed
341
+ && (existsSync(join(spawnDir, MODEL_PROMPT_DIR)) || existsSync(join(LOG_DIR, MODEL_PROMPT_DIR)))) {
342
+ // The one diagnostic case worth a warning: a system_prompt dir is configured
343
+ // but the resolved model matched no entry (likely a misnamed file). Intentional
344
+ // skips (CCV_DISABLE_AUTO_SYSTEM_PROMPT=1, or a manual --system-prompt flag
345
+ // suppressing a matched entry) carry `suppressed` and stay quiet. The
346
+ // successful-injection notice is emitted below via emitSpawnNotice (with
347
+ // internal-restart suppression); no-modelId spawns are the normal quiet path.
348
+ console.warn(`[CC Viewer] model-specific prompt: modelId="${resolvedModelId}" resolved from active config but no matching entry found in workspace or global ${MODEL_PROMPT_DIR}/`);
349
+ }
350
+ // Resolve `${...}` template variables in the injected files (editor stores them literal —
351
+ // the substitution documented by the editor's parameter reference happens here, at launch).
352
+ sysPrompt = renderSystemPromptFileArgs(sysPrompt, { cwd: spawnDir, modelId: resolvedModelId });
353
+ } catch (err) {
354
+ console.warn('[CC Viewer] system prompt build/render failed, launching without injected prompt:', err?.message || err);
309
355
  sysPrompt = { args: [], loaded: [], model: null };
310
- } else if (resolvedModelId && !sysPrompt.model
311
- && (existsSync(join(spawnDir, MODEL_PROMPT_DIR)) || existsSync(join(LOG_DIR, MODEL_PROMPT_DIR)))) {
312
- // The one diagnostic case worth a warning: a system_prompt dir is configured
313
- // but the resolved model matched no entry (likely a misnamed file). The
314
- // successful-injection notice is emitted below via emitSpawnNotice (with
315
- // internal-restart suppression); no-modelId spawns are the normal quiet path.
316
- console.warn(`[CC Viewer] model-specific prompt: modelId="${resolvedModelId}" resolved but no matching entry found in workspace or global ${MODEL_PROMPT_DIR}/`);
317
356
  }
318
357
  const launchArgs = sysPrompt.args.length ? [...finalExtraArgs, ...sysPrompt.args] : finalExtraArgs;
319
358
 
@@ -330,6 +369,9 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
330
369
  outputBuffer = '';
331
370
  currentWorkspacePath = cwd || process.cwd();
332
371
  lastWorkspacePath = currentWorkspacePath;
372
+ // Boot-window anchor for the injection fallback tiers below (same clock as the
373
+ // comparison — _now(), never Date.now(), so tests can steer both ends together).
374
+ const spawnedAt = _now();
333
375
 
334
376
  ptyProcess = pty.spawn(command, args, {
335
377
  name: 'xterm-256color',
@@ -342,13 +384,8 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
342
384
  // --allow-dangerously-skip-permissions only enables a later toggle, so it must NOT count.
343
385
  ptySkipPermissions = extraArgs.includes('--dangerously-skip-permissions');
344
386
 
345
- // 注入了 system prompt 文件时向终端打印一行提示(可见性/安全);内部重启已抑制以免重复。
346
- if (sysPrompt.loaded.length && !_suppressNextSpawnNotice) {
347
- const modelSuffix = sysPrompt.model ? ` (model match: ${sysPrompt.model})` : '';
348
- emitSpawnNotice(`[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
349
- }
350
- _suppressNextSpawnNotice = false;
351
-
387
+ // PTY 事件处理器必须紧随 spawn 注册(PR#128):若子进程在 onExit 挂载前就退出(二进制缺失/
388
+ // 秒崩/拒绝注入 flag),exit 事件会丢失——句柄释放后事件循环可能排空。注入提示挪到注册之后。
352
389
  ptyProcess.onData((data) => {
353
390
  outputBuffer += data;
354
391
  if (outputBuffer.length > MAX_BUFFER) {
@@ -363,12 +400,16 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
363
400
  }
364
401
  });
365
402
 
366
- ptyProcess.onExit(({ exitCode }) => {
403
+ ptyProcess.onExit(({ exitCode, signal }) => {
367
404
  flushBatch(true);
368
405
  lastExitCode = exitCode;
369
406
  ptyProcess = null;
370
407
  ptyKind = null;
371
408
  ptySkipPermissions = false;
409
+ // 引导期死亡:spawn 后窗口内退出。窗口外的退出一律不属于「注入拖崩启动」的兜底范围。
410
+ // 单次取 _now(评审):一级/二级共用同一时刻,注入的 fake clock 不会在分支间发散。
411
+ const elapsedMs = _now() - spawnedAt;
412
+ const diedInBootWindow = elapsedMs < SYS_PROMPT_BOOT_WINDOW_MS;
372
413
 
373
414
  // Auto-retry without -c/--continue if "No conversation found"
374
415
  // 注意:早退 return 会跳过下方的 exitListeners 广播——第一次失败的 pty 死亡对消费者
@@ -399,18 +440,48 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
399
440
  return;
400
441
  }
401
442
 
402
- // 事后兜底:claude(或三方 fork/wrapper)不识别 --system-prompt-file/--append-system-prompt-file 而崩溃时,
403
- // 记下该 claudePath、跳过注入并重启一次,避免自动注入把会话拖崩(对齐上面的 --thinking-display 自愈)。
443
+ // 事后兜底一级:注入过 system-prompt 文件的 claude 非正常死亡时,跳过注入重启一次
444
+ // (对齐上面的 --thinking-display 自愈;该确诊分支必须在前——用户自传 --thinking-display
445
+ // 且有注入时这里最多浪费一次去注入重试,第二次即广播真实报错)。两个半支的**持久性不同**:
446
+ // - 精确半支(原语义):输出含 "unknown option --system-prompt-file" —— 确诊该二进制不支持
447
+ // flag(稳定能力信号)→ 写永久拒绝集;任何场景(含 IM worker,否则它永远起不来)都自愈。
448
+ // - 放宽半支(启动兜底):引导窗口内 exit≠0 —— 注入**可能**是拖崩启动的原因(也可能是无关的
449
+ // 瞬态故障)→ 只发一次性跳过令牌,绝不写永久集(review P1:一次瞬态崩溃不得在整个进程
450
+ // 生命周期内静默禁用注入)。门控 !signal(用户 Ctrl-C/关标签/切 workspace 的 killPtyTree
451
+ // 都是信号终止,不是引导崩溃,盲目重启会把用户刚关的会话强行拉回来;Windows ConPTY 无
452
+ // POSIX 信号语义,已知限制:Ctrl-C 可能多一次无害重试)与 !insideLogDir(IM worker
453
+ // 「去注入重启」= 剥离 CC_APPEND_SYSTEM.md 人格后存活,比崩溃更难排查——IM 秒退只广播
454
+ // 真实报错)。
455
+ // 无死循环:respawn 时拒绝集/令牌把 loaded 清空 → 本分支不再命中,恰好重试一次。
456
+ // 一级重试只去掉注入、其余参数原样;根因是别的(如 API key 失效)时首次报错已实时流入
457
+ // 终端 scrollback 不丢失,第二次照常死亡并广播。
458
+ const unknownSysFileFlag = /unknown option ['"]--(append-)?system-prompt-file/i.test(outputBuffer);
459
+ const injectedBootCrash = !insideLogDir && !signal && diedInBootWindow;
404
460
  const sysFileRejected = sysPrompt.loaded.length > 0 && exitCode !== 0
405
- && /unknown option ['"]--(append-)?system-prompt-file/i.test(outputBuffer);
461
+ && (unknownSysFileFlag || injectedBootCrash);
406
462
  if (sysFileRejected) {
407
- console.error('[CC Viewer] claude rejected --system-prompt-file, skipping CC_SYSTEM.md/CC_APPEND_SYSTEM.md and retrying');
408
- _systemPromptFileRejectedPaths.add(claudePath);
463
+ if (unknownSysFileFlag) {
464
+ console.error('[CC Viewer] claude rejected --system-prompt-file, marking as unsupported and retrying without injection');
465
+ _systemPromptFileRejectedPaths.add(claudePath);
466
+ } else {
467
+ console.error(`[CC Viewer] claude exited (code ${exitCode}) ${Math.round(elapsedMs / 1000)}s after launch with injected system prompt (${sysPrompt.loaded.join(', ')}); retrying once without injection`);
468
+ _skipInjectionOncePaths.add(claudePath);
469
+ }
409
470
  _suppressNextSpawnNotice = true;
471
+ // 措辞留有余地(评审):引导期死亡可能与注入无关(API key/网络等),不断言因果。
472
+ emitSpawnNotice(`[CC Viewer] claude exited during boot (code ${exitCode}); the injected system prompt may or may not be the cause — retrying once without ${sysPrompt.loaded.join(', ')}`);
410
473
  spawnClaude(proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
411
474
  return;
412
475
  }
413
476
 
477
+ // 事后兜底二级:注入过且 exit=0 秒退 —— 无法与「用户主动快速 /exit」区分(日常高频),
478
+ // 所以只打诊断提示、不自动重启、不加入拒绝集(自动关停会因使用习惯静默禁用注入,评审否决),
479
+ // 也不早退——照常广播 exit,前端退出横幅路径与用户正常 /exit 完全一致。
480
+ // !insideLogDir:IM worker 的 pty 数据流可能被桥接转发,诊断行不该漏进 IM 会话(评审)。
481
+ if (sysPrompt.loaded.length > 0 && exitCode === 0 && diedInBootWindow && !insideLogDir) {
482
+ emitSpawnNotice(`[CC Viewer] claude exited ${Math.round(elapsedMs / 1000)}s after launch with an injected system prompt (${sysPrompt.loaded.join(', ')}). If this keeps happening the injected prompt may be incompatible — remove the entry or set CCV_DISABLE_AUTO_SYSTEM_PROMPT=1 to skip injection.`);
483
+ }
484
+
414
485
  // 保留 lastWorkspacePath,不清除,用于 respawn
415
486
  currentWorkspacePath = null;
416
487
  for (const cb of exitListeners) {
@@ -418,6 +489,14 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
418
489
  }
419
490
  });
420
491
 
492
+ // 注入了 system prompt 文件时向终端打印一行提示(可见性/安全);内部重启已抑制以免重复。
493
+ // 必须在 onData/onExit 注册之后再打(PR#128),缩小「子进程在处理器挂载前退出」的丢事件窗口。
494
+ if (sysPrompt.loaded.length && !_suppressNextSpawnNotice) {
495
+ const modelSuffix = sysPrompt.model ? ` (model match: ${sysPrompt.model})` : '';
496
+ emitSpawnNotice(`[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
497
+ }
498
+ _suppressNextSpawnNotice = false;
499
+
421
500
  return ptyProcess;
422
501
  }
423
502
 
@@ -27,14 +27,20 @@ async function dingtalkStatus(req, res, parsedUrl, isLocal, deps) {
27
27
  conn = deps.dingtalk.getBridgeStatus();
28
28
  } else {
29
29
  processInfo = await deps.dingtalk.getProcessStatus();
30
- conn = { running: processInfo.running, connected: processInfo.connected };
30
+ conn = {
31
+ running: processInfo.running,
32
+ connected: processInfo.connected,
33
+ connectionState: processInfo.connectionState,
34
+ lastError: processInfo.lastError ?? null,
35
+ };
31
36
  }
32
37
  res.writeHead(200, JSON_HEADERS);
33
38
  if (!isLocal) {
39
+ // connectionState passes through as-is; lastError stays loopback-only (mirrors routes/im.js).
34
40
  res.end(JSON.stringify({
35
41
  enabled: loadDingTalkState().enabled,
36
42
  hasSecret: loadDingTalkState().hasSecret,
37
- connection: { running: conn.running, connected: conn.connected },
43
+ connection: { running: conn.running, connected: conn.connected, connectionState: conn.connectionState },
38
44
  }));
39
45
  return;
40
46
  }
@@ -93,7 +99,7 @@ function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
93
99
  console.error('[CC Viewer] IM config apply failed for dingtalk:', e?.message || e);
94
100
  }
95
101
  res.writeHead(200, JSON_HEADERS);
96
- res.end(JSON.stringify({ ...loadDingTalkState(), connection: { running: !!(saved?.enabled ?? incoming.enabled), connected: false } }));
102
+ res.end(JSON.stringify({ ...loadDingTalkState(), connection: { running: !!(saved?.enabled ?? incoming.enabled), connected: false, connectionState: 'disconnected' } }));
97
103
  });
98
104
  }
99
105
 
@@ -8,6 +8,11 @@
8
8
  // runs with --dangerously-skip-permissions.
9
9
  // POST /api/im/:platform/test — loopback-only; validate creds (fetch an access token).
10
10
  // POST /api/im/:platform/process — loopback-only; {action:start|stop|restart} the detached worker.
11
+ // 'start' requires stored creds (400 `missing …` otherwise) and
12
+ // also persists enabled:true (merged into the stored config):
13
+ // a worker spawned while the config says disabled no-ops in
14
+ // im-bridge-core and would not survive a restart reconcile,
15
+ // so "start" must mean "enable + spawn".
11
16
  // GET /api/im/:platform/logs — resolve the worker's latest .jsonl (for the records popup).
12
17
  //
13
18
  // Architecture: IM adapters no longer run in the main ccv. Each enabled IM runs as an independent
@@ -56,6 +61,27 @@ function secretKeys(id) {
56
61
  return getDescriptor(id).fields.filter((f) => f.type === 'secret').map((f) => f.key);
57
62
  }
58
63
 
64
+ /** Required cred/secret field keys that are empty in `cfg` (same gate as the /test route). */
65
+ function missingCreds(id, cfg) {
66
+ return getDescriptor(id).fields
67
+ .filter((f) => (f.type === 'cred' || f.type === 'secret') && !cfg[f.key])
68
+ .map((f) => f.key);
69
+ }
70
+
71
+ // Audit every path that flips a platform on with an empty sender allowlist (bind-first-conversation
72
+ // under --dangerously-skip-permissions). Shared by config POST and process 'start' so no enabling
73
+ // path can bypass the server-side warning — headless callers never see the frontend toast.
74
+ function warnIfEmptyAllowlist(id, cfg) {
75
+ const allowField = getDescriptor(id).allowListField;
76
+ // 过滤空白项后再判空:saveConfig 会 normalize(trim+丢空),若只看原始长度,[" "] 这类全空白
77
+ // 白名单会被当成"已配置"而漏掉审计告警,但实际保存的是空名单(与 dingtalk 路由保持一致)。
78
+ const raw = Array.isArray(cfg[allowField]) ? cfg[allowField] : [];
79
+ const list = raw.filter((s) => typeof s === 'string' && s.trim());
80
+ if (list.length === 0) {
81
+ console.warn(`[CC Viewer] IM ${id} enabled with EMPTY allowlist — bind-first-conversation; the first conversation to message can drive this --dangerously-skip-permissions session`);
82
+ }
83
+ }
84
+
59
85
  function readBody(req, deps, cb) {
60
86
  let body = '';
61
87
  req.on('data', (chunk) => {
@@ -79,16 +105,22 @@ async function imStatus(req, res, parsedUrl, isLocal, deps) {
79
105
  } else {
80
106
  // MAIN: the adapter runs in a detached worker, not here. Resolve process+connection via manager.
81
107
  processInfo = await deps.im.getProcessStatus(id);
82
- connection = { running: processInfo.running, connected: processInfo.connected };
108
+ connection = {
109
+ running: processInfo.running,
110
+ connected: processInfo.connected,
111
+ connectionState: processInfo.connectionState,
112
+ lastError: processInfo.lastError ?? null,
113
+ };
83
114
  }
84
115
 
85
116
  res.writeHead(200, JSON_HEADERS);
86
117
  if (!isLocal) {
87
118
  // Loopback gate: a token-authorized LAN client sees only what the header chip needs.
119
+ // connectionState is passed through as-is (no defaulting); lastError stays loopback-only.
88
120
  res.end(JSON.stringify({
89
121
  enabled: state.enabled,
90
122
  hasSecret: state.hasSecret,
91
- connection: { running: connection.running, connected: connection.connected },
123
+ connection: { running: connection.running, connected: connection.connected, connectionState: connection.connectionState },
92
124
  }));
93
125
  return;
94
126
  }
@@ -124,16 +156,7 @@ function imConfigPost(req, res, parsedUrl, isLocal, deps) {
124
156
  // bind-first-conversation(im-bridge-core.js)——首个向机器人发消息的会话被绑定,该会话内任何人
125
157
  // 都可无审批驱动本地会话。这里打一条服务端审计(curl/headless 启用走不到前端 toast),
126
158
  // PreToolUse permissions.deny 硬拦截(perm-bridge/im-deny,独立于白名单)仍然生效。
127
- if (incoming.enabled) {
128
- const allowField = getDescriptor(id).allowListField;
129
- // 过滤空白项后再判空:saveConfig 会 normalize(trim+丢空),若只看原始长度,[" "] 这类全空白
130
- // 白名单会被当成"已配置"而漏掉审计告警,但实际保存的是空名单(与 dingtalk 路由保持一致)。
131
- const raw = Array.isArray(incoming[allowField]) ? incoming[allowField] : [];
132
- const list = raw.filter((s) => typeof s === 'string' && s.trim());
133
- if (list.length === 0) {
134
- console.warn(`[CC Viewer] IM ${id} enabled with EMPTY allowlist — bind-first-conversation; the first conversation to message can drive this --dangerously-skip-permissions session`);
135
- }
136
- }
159
+ if (incoming.enabled) warnIfEmptyAllowlist(id, incoming);
137
160
  const saved = saveConfig(id, incoming);
138
161
  // applyProcess(默认 true,保持旧调用方语义):前端 onBlur 自动保存传 false → 仅存盘、不驱动进程,
139
162
  // 否则每次输入框失焦都会重启 worker。显式「启动/停止」按钮则不传(=true),沿用下述驱动逻辑。
@@ -151,7 +174,7 @@ function imConfigPost(req, res, parsedUrl, isLocal, deps) {
151
174
  }
152
175
  res.writeHead(200, JSON_HEADERS);
153
176
  // 乐观返回:worker 刚 spawn 尚未就绪,避免回包瞬间显示"已停止";chip 轮询会很快收敛到真实态。
154
- res.end(JSON.stringify({ ...loadState(id), connection: { running: !!saved.enabled, connected: false } }));
177
+ res.end(JSON.stringify({ ...loadState(id), connection: { running: !!saved.enabled, connected: false, connectionState: 'disconnected' } }));
155
178
  });
156
179
  }
157
180
 
@@ -165,9 +188,7 @@ function imTestPost(req, res, parsedUrl, isLocal, deps) {
165
188
  const stored = loadConfig(id);
166
189
  const cfg = {};
167
190
  for (const f of getDescriptor(id).fields) cfg[f.key] = incoming[f.key] || stored[f.key];
168
- const missing = getDescriptor(id).fields
169
- .filter((f) => (f.type === 'cred' || f.type === 'secret') && !cfg[f.key])
170
- .map((f) => f.key);
191
+ const missing = missingCreds(id, cfg);
171
192
  if (missing.length) {
172
193
  res.writeHead(200, JSON_HEADERS);
173
194
  res.end(JSON.stringify({ ok: false, detail: `missing ${missing.join('/')}` }));
@@ -195,8 +216,25 @@ function imProcessPost(req, res, parsedUrl, isLocal, deps) {
195
216
  try {
196
217
  if (action === 'stop') await deps.im.stopProcess(id);
197
218
  else if (action === 'restart') await deps.im.restartProcess(id);
198
- else if (action === 'start') await deps.im.startProcess(id);
199
- else {
219
+ else if (action === 'start') {
220
+ const cfg = loadConfig(id);
221
+ // Credential gate (same as /test): without creds the spawned worker's bridge no-ops
222
+ // forever, and persisting enabled:true would make reconcile respawn that zombie on
223
+ // every server restart. Reject up front — nothing is persisted, nothing is spawned.
224
+ const missing = missingCreds(id, cfg);
225
+ if (missing.length) {
226
+ res.writeHead(400, JSON_HEADERS);
227
+ res.end(JSON.stringify({ ok: false, error: `missing ${missing.join('/')}`, detail: `missing ${missing.join('/')}` }));
228
+ return;
229
+ }
230
+ // Persist enabled:true first (read-merge-write keeps creds/allowlist intact): a worker
231
+ // spawned with enabled:false no-ops its bridge, and reconcile would not respawn it.
232
+ if (!cfg.enabled) {
233
+ warnIfEmptyAllowlist(id, cfg);
234
+ saveConfig(id, { ...cfg, enabled: true });
235
+ }
236
+ await deps.im.startProcess(id);
237
+ } else {
200
238
  res.writeHead(400, JSON_HEADERS);
201
239
  res.end(JSON.stringify({ error: 'action must be start|stop|restart' }));
202
240
  return;