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.
- package/README.md +1 -1
- package/dist/assets/App-VTo4NVdo.js +2 -0
- package/dist/assets/App-dYPa5-df.css +1 -0
- package/dist/assets/{MdxEditorPanel-C8ITEOzT.js → MdxEditorPanel-ualoRFOL.js} +1 -1
- package/dist/assets/{Mobile-DCHg97f1.js → Mobile-CcmYvMm_.js} +1 -1
- package/dist/assets/index-DCo650PG.js +2 -0
- package/dist/assets/{seqResourceLoaders-B3TSSS6T.css → seqResourceLoaders-B5Zy_CC_.css} +1 -1
- package/dist/assets/seqResourceLoaders-BVozq68i.js +2 -0
- package/dist/index.html +1 -1
- package/findcc.js +75 -4
- package/package.json +1 -1
- package/server/lib/adapters/dingtalk-adapter.js +16 -2
- package/server/lib/adapters/discord-adapter.js +28 -6
- package/server/lib/adapters/feishu-adapter.js +50 -4
- package/server/lib/adapters/wecom-adapter.js +24 -0
- package/server/lib/create_system_prompt.js +13 -7
- package/server/lib/im-bridge-core.js +71 -4
- package/server/lib/im-lock.js +16 -2
- package/server/lib/im-process-manager.js +7 -1
- package/server/lib/model-system-prompts.js +10 -6
- package/server/lib/spawn-model-resolver.js +109 -0
- package/server/lib/system-prompt-files.js +7 -3
- package/server/lib/system-prompt-render.js +79 -0
- package/server/pty-manager.js +115 -36
- package/server/routes/dingtalk.js +9 -3
- package/server/routes/im.js +56 -18
- package/server/server.js +12 -1
- package/dist/assets/App-BjHJ9KJ6.css +0 -1
- package/dist/assets/App-CCmR0aki.js +0 -2
- package/dist/assets/index-CfPXhStV.js +0 -2
- package/dist/assets/seqResourceLoaders-CAYxnMSH.js +0 -2
|
@@ -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
|
+
}
|
package/server/pty-manager.js
CHANGED
|
@@ -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 {
|
|
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
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
export
|
|
60
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
|
|
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
|
-
//
|
|
346
|
-
|
|
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
|
-
//
|
|
403
|
-
//
|
|
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
|
-
&&
|
|
461
|
+
&& (unknownSysFileFlag || injectedBootCrash);
|
|
406
462
|
if (sysFileRejected) {
|
|
407
|
-
|
|
408
|
-
|
|
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 = {
|
|
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
|
|
package/server/routes/im.js
CHANGED
|
@@ -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 = {
|
|
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 =
|
|
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')
|
|
199
|
-
|
|
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;
|