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
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="./assets/index-
|
|
24
|
+
<script type="module" crossorigin src="./assets/index-DCo650PG.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="./assets/vendor-antd-D5HmSDSg.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-C5yuj7ze.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-BcEooTvb.js">
|
package/findcc.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve, join } from 'node:path';
|
|
1
|
+
import { resolve, join, sep } from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { existsSync, realpathSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { homedir, tmpdir, arch } from 'node:os';
|
|
@@ -22,11 +22,42 @@ const NODE_MODULES = resolve(__dirname, '..');
|
|
|
22
22
|
* Claude Code's config from ~/.claude/ to a custom location.
|
|
23
23
|
* @returns {string} absolute path to the Claude config directory
|
|
24
24
|
*/
|
|
25
|
+
// ████████ Test isolation barrier L1c helper — DO NOT REMOVE (2026-07-12 data loss) ████████
|
|
26
|
+
// A ccv-hosted shell exports CCV_LOG_DIR=<real user data dir> (and possibly CLAUDE_CONFIG_DIR)
|
|
27
|
+
// into every child process — the claude pty, its Bash tool, any nested shell. A direct
|
|
28
|
+
// `node --test <file>` run there inherits those vars and sails through the explicit-value fast
|
|
29
|
+
// paths below, handing the test process the REAL user directories; test fixtures/cleanup then
|
|
30
|
+
// delete real user data (confirmed 2026-07-12: a pty-manager fixture's finally-rmSync wiped the
|
|
31
|
+
// user's global system_prompt/ model prompts). Policy: a test process may only ever target
|
|
32
|
+
// disposable temp directories. This helper decides whether an explicit dir qualifies.
|
|
33
|
+
function isDisposableTmpPath(p) {
|
|
34
|
+
const roots = new Set();
|
|
35
|
+
const t = resolve(tmpdir());
|
|
36
|
+
roots.add(t);
|
|
37
|
+
try { roots.add(realpathSync(t)); } catch { /* keep the unresolved form */ }
|
|
38
|
+
if (process.platform !== 'win32') { roots.add('/tmp'); roots.add('/private/tmp'); }
|
|
39
|
+
const forms = new Set([resolve(p)]);
|
|
40
|
+
try { forms.add(realpathSync(resolve(p))); } catch { /* path may not exist yet */ }
|
|
41
|
+
for (const f of forms) {
|
|
42
|
+
for (const r of roots) {
|
|
43
|
+
if (f === r || f.startsWith(r + sep)) return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
25
49
|
export function getClaudeConfigDir() {
|
|
26
50
|
const envDir = process.env.CLAUDE_CONFIG_DIR;
|
|
27
51
|
if (envDir && typeof envDir === 'string' && envDir.trim()) {
|
|
28
52
|
const raw = envDir.trim();
|
|
29
|
-
|
|
53
|
+
const resolved = raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : resolve(raw);
|
|
54
|
+
// ████ L1d: in test context an explicit CLAUDE_CONFIG_DIR must still be a throwaway dir —
|
|
55
|
+
// an inherited real config dir would re-open the 2026-06-06 updater CACHE_DIR hole. ████
|
|
56
|
+
if (process.env.NODE_TEST_CONTEXT && !isDisposableTmpPath(resolved)) {
|
|
57
|
+
console.warn(`[findcc] L1d test-isolation barrier: CLAUDE_CONFIG_DIR="${raw}" is not under the OS temp dir — forcing a private guard config dir (tests may only target disposable temp dirs)`);
|
|
58
|
+
return join(tmpdir(), 'cc-viewer-test', `guard-cfg-${process.pid}-${threadId}`);
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
30
61
|
}
|
|
31
62
|
// ████████ Test isolation barrier L1b — DO NOT REMOVE (prevents data-loss regressions, 2026-06-06) ████████
|
|
32
63
|
// CCV_LOG_DIR=tmp only redirects LOG_DIR; it does not cover this function: updater.js's
|
|
@@ -69,7 +100,21 @@ function resolveLogDir() {
|
|
|
69
100
|
return join(tmpdir(), 'cc-viewer-test', `${process.pid}-${threadId}`);
|
|
70
101
|
}
|
|
71
102
|
const expanded = raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : raw;
|
|
72
|
-
|
|
103
|
+
const resolved = resolve(expanded);
|
|
104
|
+
// ████████ Test isolation barrier L1c — DO NOT REMOVE (2026-07-12 data loss) ████████
|
|
105
|
+
// The NODE_TEST_CONTEXT guard below only covers the no-CCV_LOG_DIR case; this explicit-value
|
|
106
|
+
// fast path used to accept ANY inherited dir. Inside a ccv-hosted shell CCV_LOG_DIR points at
|
|
107
|
+
// the real ~/.claude/cc-viewer, so a direct `node --test <file>` there resolved LOG_DIR to
|
|
108
|
+
// real user data — test cleanup then deleted it (2026-07-12: the user's global system_prompt/
|
|
109
|
+
// entries were wiped this way, twice). Tests may only target disposable temp dirs: anything
|
|
110
|
+
// outside the OS temp root is forced to the private guard dir. Use CCV_LOG_DIR=tmp or a
|
|
111
|
+
// mkdtemp path in tests. Unit test: test/logdir-test-guard.test.js.
|
|
112
|
+
if (process.env.NODE_TEST_CONTEXT && !isDisposableTmpPath(resolved)) {
|
|
113
|
+
console.warn(`[findcc] L1c test-isolation barrier: CCV_LOG_DIR="${raw}" is not under the OS temp dir — forcing a private guard LOG_DIR (tests may only target disposable temp dirs; use CCV_LOG_DIR=tmp or a mkdtemp path)`);
|
|
114
|
+
return join(tmpdir(), 'cc-viewer-test', `guard-${process.pid}-${threadId}`);
|
|
115
|
+
}
|
|
116
|
+
// ████████████████████████████████████████████████████████████████████████████
|
|
117
|
+
return resolved;
|
|
73
118
|
}
|
|
74
119
|
// Test isolation barrier: in node:test environment (NODE_TEST_CONTEXT is auto-injected by the
|
|
75
120
|
// test runner and inherited by spawned child processes via spread env), if CCV_LOG_DIR is not
|
|
@@ -234,10 +279,16 @@ export function resolveNpmClaudePath() {
|
|
|
234
279
|
const match = normReal.match(/(.*node_modules\/@[^/]+\/[^/]+)\//);
|
|
235
280
|
if (match) {
|
|
236
281
|
const packageDir = match[1];
|
|
282
|
+
// Claude Code 1.x: cli.js (injected with interceptor)
|
|
237
283
|
const cliPath = join(packageDir, CLI_ENTRY);
|
|
238
284
|
if (existsSync(cliPath)) {
|
|
239
285
|
return cliPath;
|
|
240
286
|
}
|
|
287
|
+
// Claude Code 2.x layout ships no cli.js — the package's bin/ binary IS
|
|
288
|
+
// the entry point; without this fallback the resolver returns null and
|
|
289
|
+
// the launch dies with no claude at all.
|
|
290
|
+
const binPath = findNpmBinFallback(packageDir);
|
|
291
|
+
if (binPath) return binPath;
|
|
241
292
|
}
|
|
242
293
|
}
|
|
243
294
|
} catch { }
|
|
@@ -255,16 +306,36 @@ export function resolveNpmClaudePath() {
|
|
|
255
306
|
const globalRoot = getGlobalNodeModulesDir();
|
|
256
307
|
if (globalRoot) {
|
|
257
308
|
for (const packageName of PACKAGES) {
|
|
258
|
-
const
|
|
309
|
+
const pkgDir = join(globalRoot, packageName);
|
|
310
|
+
// Claude Code 1.x: cli.js
|
|
311
|
+
const cliPath = join(pkgDir, CLI_ENTRY);
|
|
259
312
|
if (existsSync(cliPath)) {
|
|
260
313
|
return cliPath;
|
|
261
314
|
}
|
|
315
|
+
// Claude Code 2.x: no cli.js, fall back to the package's bin/ entry.
|
|
316
|
+
const binPath = findNpmBinFallback(pkgDir);
|
|
317
|
+
if (binPath) return binPath;
|
|
262
318
|
}
|
|
263
319
|
}
|
|
264
320
|
|
|
265
321
|
return null;
|
|
266
322
|
}
|
|
267
323
|
|
|
324
|
+
// Claude Code 2.x npm packages ship no cli.js — bin/claude(.exe) is the entry point.
|
|
325
|
+
// `claude.exe` is checked FIRST even on POSIX: 2.1.x's package.json maps
|
|
326
|
+
// `bin: {"claude": "bin/claude.exe"}` on every platform (verified on macOS 2.1.207,
|
|
327
|
+
// where bin/claude.exe is the platform-native binary hard-linked into bin/).
|
|
328
|
+
// Returns the first existing bin candidate under `<pkgDir>/bin`, or null (1.x layout).
|
|
329
|
+
function findNpmBinFallback(pkgDir) {
|
|
330
|
+
const binDir = join(pkgDir, 'bin');
|
|
331
|
+
const names = process.platform === 'win32' ? ['claude.exe', 'claude.cmd'] : ['claude.exe', 'claude'];
|
|
332
|
+
for (const name of names) {
|
|
333
|
+
const binPath = join(binDir, name);
|
|
334
|
+
if (existsSync(binPath)) return binPath;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
268
339
|
/**
|
|
269
340
|
* 从 which/where 的原始输出中挑出能直接 CreateProcess/exec 的候选行。
|
|
270
341
|
* Windows 的 `where` 会列出 PATH 中全部同名匹配——npm 全局安装时第一行往往是给
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.348",
|
|
4
4
|
"description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
|
@@ -179,21 +179,35 @@ const dingtalkAdapter = {
|
|
|
179
179
|
|
|
180
180
|
async connect(cfg, hooks) {
|
|
181
181
|
let client;
|
|
182
|
+
// keepAlive is required for silent-drop detection: without it the SDK never pings, so a
|
|
183
|
+
// network loss with no FIN leaves the socket "open" and `client.connected` true forever.
|
|
184
|
+
// Known SDK quirk: dingtalk-stream arms a new heartbeat interval on every socket 'open'
|
|
185
|
+
// without clearing the previous one, so each reconnect leaks one 8s interval (bounded by
|
|
186
|
+
// reconnect count; disconnect() clears the last). Accepted — detection matters more.
|
|
187
|
+
const opts = { clientId: cfg.appKey, clientSecret: cfg.appSecret, keepAlive: true };
|
|
182
188
|
if (clientFactory) {
|
|
183
|
-
client = clientFactory(
|
|
189
|
+
client = clientFactory(opts);
|
|
184
190
|
if (typeof client.registerCallbackListener === 'function') {
|
|
185
191
|
client.registerCallbackListener('__test__', (res) => hooks.onInbound(normalizeInbound(res), res));
|
|
186
192
|
}
|
|
187
193
|
} else {
|
|
188
194
|
const mod = await import('dingtalk-stream');
|
|
189
195
|
const { DWClient, TOPIC_ROBOT } = mod;
|
|
190
|
-
client = new DWClient(
|
|
196
|
+
client = new DWClient(opts);
|
|
191
197
|
client.registerCallbackListener(TOPIC_ROBOT, (res) => hooks.onInbound(normalizeInbound(res), res));
|
|
192
198
|
}
|
|
193
199
|
await client.connect?.();
|
|
194
200
|
return client;
|
|
195
201
|
},
|
|
196
202
|
|
|
203
|
+
// dingtalk-stream emits no client-level lifecycle events (open/close/error live on an internal
|
|
204
|
+
// ws that is replaced on every reconnect), so the core polls this instead. `userDisconnect` is
|
|
205
|
+
// TS-private but a plain runtime field; it flips true only on manual disconnect().
|
|
206
|
+
connectionProbe(client) {
|
|
207
|
+
if (!client || client.userDisconnect) return 'disconnected';
|
|
208
|
+
return client.connected ? 'connected' : 'reconnecting';
|
|
209
|
+
},
|
|
210
|
+
|
|
197
211
|
async disconnect(client) {
|
|
198
212
|
try { await client?.disconnect?.(); } catch { /* best-effort */ }
|
|
199
213
|
},
|
|
@@ -80,10 +80,21 @@ const discordAdapter = {
|
|
|
80
80
|
});
|
|
81
81
|
ctx.store.client = client;
|
|
82
82
|
// Persistent error listener for the life of the client: a Node EventEmitter that emits 'error'
|
|
83
|
-
// with NO listener throws and crashes the process.
|
|
84
|
-
// consumed
|
|
85
|
-
//
|
|
86
|
-
|
|
83
|
+
// with NO listener throws and crashes the process. NOTE: the connect-window once('error') below
|
|
84
|
+
// is NOT consumed by a successful ClientReady resolve — it survives and must therefore be a
|
|
85
|
+
// settled-guarded no-op post-connect (see below). Record errors as lastError here (state flips
|
|
86
|
+
// come from the shard lifecycle events below; the gateway auto-reconnects).
|
|
87
|
+
client.on('error', (e) => hooks.onConnectionChange?.(null, e));
|
|
88
|
+
// Shard lifecycle → core tri-state. Single unsharded gateway = one shard, so no per-shard map.
|
|
89
|
+
// Events?.X ?? 'literal' keeps test fakes (whose Events map lacks shard constants) working.
|
|
90
|
+
client.on(Events?.ShardReconnecting ?? 'shardReconnecting', () => hooks.onConnectionChange?.('reconnecting'));
|
|
91
|
+
client.on(Events?.ShardResume ?? 'shardResume', () => hooks.onConnectionChange?.('connected'));
|
|
92
|
+
client.on(Events?.ShardReady ?? 'shardReady', () => hooks.onConnectionChange?.('connected'));
|
|
93
|
+
client.on(Events?.ShardDisconnect ?? 'shardDisconnect', (ev) =>
|
|
94
|
+
hooks.onConnectionChange?.('disconnected', new Error(`gateway closed (code ${ev?.code ?? 'unknown'})`)));
|
|
95
|
+
client.on(Events?.Invalidated ?? 'invalidated', () =>
|
|
96
|
+
hooks.onConnectionChange?.('disconnected', new Error('session invalidated')));
|
|
97
|
+
client.on(Events?.ShardError ?? 'shardError', (e) => hooks.onConnectionChange?.(null, e));
|
|
87
98
|
client.on(Events.MessageCreate, (message) => {
|
|
88
99
|
// Loop-guard: ignore the bot's own messages (Discord redelivers our send as a new event) and
|
|
89
100
|
// all other bots. author.bot covers self; the id check is belt-and-suspenders.
|
|
@@ -98,8 +109,19 @@ const discordAdapter = {
|
|
|
98
109
|
const timer = setTimeout(() => { try { client.destroy(); } catch { /* best-effort */ } finish(reject, new Error('connect timeout')); }, CONNECT_PROBE_MS);
|
|
99
110
|
if (typeof timer.unref === 'function') timer.unref();
|
|
100
111
|
client.once(Events.ClientReady, () => finish(resolve, client));
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
// Guard the destroy behind `settled`: this once-listener is NOT consumed by ClientReady, so
|
|
113
|
+
// an unguarded destroy would kill the LIVE client on the first post-connect 'error' event
|
|
114
|
+
// (freezing the bridge with no reconnect — the exact stale-status bug this adapter fixes).
|
|
115
|
+
client.once('error', (e) => {
|
|
116
|
+
if (settled) return; // post-connect errors are handled by the persistent listener above
|
|
117
|
+
try { client.destroy(); } catch { /* best-effort */ }
|
|
118
|
+
finish(reject, new Error(String(e?.message || e)));
|
|
119
|
+
});
|
|
120
|
+
client.login(cfg.botToken).catch((e) => {
|
|
121
|
+
if (settled) return;
|
|
122
|
+
try { client.destroy(); } catch { /* best-effort */ }
|
|
123
|
+
finish(reject, new Error(String(e?.message || e)));
|
|
124
|
+
});
|
|
103
125
|
});
|
|
104
126
|
},
|
|
105
127
|
|
|
@@ -18,6 +18,10 @@ import { registerAdapter } from '../im-bridge-core.js';
|
|
|
18
18
|
let sdkFactory = null;
|
|
19
19
|
export function __setClientFactory(fn) { sdkFactory = fn; }
|
|
20
20
|
|
|
21
|
+
// Internal connect guard (< core CONNECT_TIMEOUT_MS): bounds the onReady wait on hook-capable SDK
|
|
22
|
+
// builds so a misconfigured app fails with lastError instead of leaking a background retry loop.
|
|
23
|
+
const CONNECT_PROBE_MS = 12_000;
|
|
24
|
+
|
|
21
25
|
const TOKEN_PATH = '/open-apis/auth/v3/tenant_access_token/internal';
|
|
22
26
|
function tokenHost(region) {
|
|
23
27
|
return region === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
|
|
@@ -90,17 +94,59 @@ const feishuAdapter = {
|
|
|
90
94
|
const base = { appId: cfg.appId, appSecret: cfg.appSecret, domain };
|
|
91
95
|
// Outbound client (auto-manages the tenant_access_token cache).
|
|
92
96
|
ctx.store.sendClient = new Lark.Client(base);
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
let settled = false;
|
|
98
|
+
let resolveReady, rejectReady;
|
|
99
|
+
const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
|
|
100
|
+
ready.catch(() => {}); // detached guard: a pre-await rejection must not raise unhandledRejection
|
|
101
|
+
// Inbound long-connection event client. wsConfig keys per SDK build: 1.66.0 reads ONLY the
|
|
102
|
+
// lowercase `pingTimeout` (seconds) — it arms the pong/liveness watchdog that terminates a
|
|
103
|
+
// zombie socket after a SILENT network drop (NAT idle, sleep, no FIN) and triggers reconnect;
|
|
104
|
+
// without it such drops are never detected. The capitalized PingInterval/PingTimeout keys are
|
|
105
|
+
// kept for older builds that read them (1.66.0 ignores unknown keys, so both are safe).
|
|
95
106
|
const ws = new Lark.WSClient({
|
|
96
107
|
...base,
|
|
97
108
|
loggerLevel: Lark.LoggerLevel ? Lark.LoggerLevel.warn : undefined, // warn: avoid noisy info-level stdout
|
|
98
|
-
wsConfig: { PingInterval: 30, PingTimeout: 5 },
|
|
109
|
+
wsConfig: { PingInterval: 30, PingTimeout: 5, pingTimeout: 5 },
|
|
110
|
+
// Lifecycle hooks ship with newer SDK builds (present in the installed 1.66.0); an older
|
|
111
|
+
// build silently ignores unknown ctor options, so wiring them is always safe.
|
|
112
|
+
onReady: () => {
|
|
113
|
+
if (settled) { hooks.onConnectionChange?.('connected'); return; }
|
|
114
|
+
settled = true; resolveReady();
|
|
115
|
+
},
|
|
116
|
+
onReconnecting: () => hooks.onConnectionChange?.('reconnecting'),
|
|
117
|
+
onReconnected: () => hooks.onConnectionChange?.('connected'),
|
|
118
|
+
onError: (err) => {
|
|
119
|
+
if (settled) { hooks.onConnectionChange?.('disconnected', err); return; }
|
|
120
|
+
settled = true; rejectReady(err instanceof Error ? err : new Error(String(err?.message || err)));
|
|
121
|
+
},
|
|
99
122
|
});
|
|
100
123
|
const dispatcher = new Lark.EventDispatcher({}).register({
|
|
101
124
|
'im.message.receive_v1': async (data) => hooks.onInbound(normalizeInbound(data), null),
|
|
102
125
|
});
|
|
103
|
-
|
|
126
|
+
if (typeof ws.getConnectionStatus === 'function') {
|
|
127
|
+
// Hook-capable build (feature-detected): start() resolves before the handshake completes,
|
|
128
|
+
// so gate on onReady/onError; bound with an internal timeout below the core's connect race.
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
if (settled) return;
|
|
131
|
+
settled = true;
|
|
132
|
+
rejectReady(new Error('connect timeout'));
|
|
133
|
+
}, CONNECT_PROBE_MS);
|
|
134
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
135
|
+
try {
|
|
136
|
+
await ws.start({ eventDispatcher: dispatcher });
|
|
137
|
+
await ready;
|
|
138
|
+
} catch (e) {
|
|
139
|
+
// Stop the SDK's background retry loop so a failed connect doesn't leak.
|
|
140
|
+
try { await ws.close?.({}); } catch { /* best-effort */ }
|
|
141
|
+
throw e;
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
// Older SDK (and legacy test fakes): original behavior — start() is trusted, no
|
|
147
|
+
// lifecycle hooks fire, connection state stays start/stop-driven.
|
|
148
|
+
await ws.start({ eventDispatcher: dispatcher });
|
|
149
|
+
}
|
|
104
150
|
return ws;
|
|
105
151
|
},
|
|
106
152
|
|
|
@@ -75,6 +75,30 @@ const wecomAdapter = {
|
|
|
75
75
|
const client = new WSClient({ botId: cfg.botId, secret: cfg.secret });
|
|
76
76
|
ctx.store.client = client; // single client for inbound + outbound (sendOne reads it)
|
|
77
77
|
client.on('message.text', (frame) => hooks.onInbound(normalizeInbound(frame), null));
|
|
78
|
+
// Persistent lifecycle listeners → core tri-state. Registered up front (pre-connect emits are
|
|
79
|
+
// ignored by the core while not running); removed by disconnect()'s removeAllListeners.
|
|
80
|
+
// NOTE: one connection per bot — running testConnection against the SAME bot kicks the live
|
|
81
|
+
// bridge via disconnected_event, which the SDK treats as TERMINAL (isManualClose, no
|
|
82
|
+
// reconnect). The status will correctly show Disconnected until the bridge is restarted;
|
|
83
|
+
// auto-restart-after-test is a deliberate non-goal for now (backlog).
|
|
84
|
+
let kicked = false;
|
|
85
|
+
client.on('event.disconnected_event', () => {
|
|
86
|
+
// Server kicked this connection (a new one took over): the SDK sets isManualClose and will
|
|
87
|
+
// NOT reconnect, so this is terminal; the 'disconnected' emit that follows must not
|
|
88
|
+
// downgrade it to reconnecting.
|
|
89
|
+
kicked = true;
|
|
90
|
+
hooks.onConnectionChange?.('disconnected', new Error('kicked: new connection established'));
|
|
91
|
+
});
|
|
92
|
+
client.on('disconnected', (reason) => {
|
|
93
|
+
if (!kicked) hooks.onConnectionChange?.('reconnecting', reason ? new Error(String(reason)) : null);
|
|
94
|
+
});
|
|
95
|
+
client.on('reconnecting', () => hooks.onConnectionChange?.('reconnecting'));
|
|
96
|
+
client.on('authenticated', () => { kicked = false; hooks.onConnectionChange?.('connected'); });
|
|
97
|
+
client.on('error', (e) => {
|
|
98
|
+
const terminal = e?.name === 'WSReconnectExhaustedError' || e?.name === 'WSAuthFailureError'
|
|
99
|
+
|| e?.code === 'WS_RECONNECT_EXHAUSTED';
|
|
100
|
+
hooks.onConnectionChange?.(terminal ? 'disconnected' : null, e);
|
|
101
|
+
});
|
|
78
102
|
// connect() is synchronous; resolve once the WS handshake authenticates so the core's
|
|
79
103
|
// `connected` flag and CONNECT_TIMEOUT_MS race are meaningful (bad creds → lastError, not a
|
|
80
104
|
// false "connected"). Reject (and tear down) on error or an internal timeout.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// node server/lib/create_system_prompt.js deepseek-v4-pro # a named preset
|
|
8
8
|
// node server/lib/create_system_prompt.js --list # list presets
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { spawnSync } from 'node:child_process'
|
|
11
11
|
import { readFileSync, statSync } from 'node:fs'
|
|
12
12
|
import os from 'node:os'
|
|
13
13
|
import { delimiter, join, sep } from 'node:path'
|
|
@@ -54,13 +54,16 @@ function envString(name) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function commandOutput(command, args, cwd) {
|
|
57
|
-
return stringOrEmpty(() =>
|
|
58
|
-
|
|
57
|
+
return stringOrEmpty(() => {
|
|
58
|
+
const result = spawnSync(command, args, {
|
|
59
59
|
cwd,
|
|
60
60
|
encoding: 'utf8',
|
|
61
61
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
timeout: 15000, // 15s hard cap: hanging git (NFS / huge repo / broken index) must not block spawn
|
|
63
|
+
})
|
|
64
|
+
if (result.error || result.status !== 0) return ''
|
|
65
|
+
return result.stdout.trim()
|
|
66
|
+
})
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
function firstNonEmpty(...values) {
|
|
@@ -130,8 +133,11 @@ function resolveMemory(home, cwd) {
|
|
|
130
133
|
return { dir, index, enabled: enabled ? 'true' : 'false' }
|
|
131
134
|
}
|
|
132
135
|
|
|
133
|
-
export function createSystemPromptVariables(overrides = {}) {
|
|
134
|
-
|
|
136
|
+
export function createSystemPromptVariables(overrides = {}, opts = {}) {
|
|
137
|
+
// opts.cwd: resolve cwd-dependent variables (environment.cwd, git.*, memory.dir) against a
|
|
138
|
+
// caller-supplied directory instead of process.cwd() — the spawn-time renderer passes the
|
|
139
|
+
// workspace being launched, which is not necessarily where the ccv server itself runs.
|
|
140
|
+
const cwd = (typeof opts.cwd === 'string' && opts.cwd) ? opts.cwd : stringOrEmpty(() => process.cwd())
|
|
135
141
|
const now = new Date()
|
|
136
142
|
const timeZone = stringOrEmpty(
|
|
137
143
|
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
@@ -43,6 +43,10 @@ const STREAM_MIN_DELTA = 20; // 累计增长达 20 字才推一帧
|
|
|
43
43
|
const STREAM_MAX_FRAMES = 25; // 每回合中途流式帧硬上限;超限停推,finalize 仍落全文
|
|
44
44
|
const STREAM_MAX_CHARS = 20_000; // 卡片内容字符上限,超出截断
|
|
45
45
|
|
|
46
|
+
// Poll interval for adapters that expose connectionProbe() instead of lifecycle events
|
|
47
|
+
// (dingtalk-stream replaces its internal ws on every reconnect, so no listener survives).
|
|
48
|
+
let CONN_POLL_MS = 5_000;
|
|
49
|
+
|
|
46
50
|
// ─── registry + core-global single-flight ───
|
|
47
51
|
const instances = new Map(); // platformId → instance
|
|
48
52
|
let activeInjection = null; // { platformId, since, target } — the one in-flight turn
|
|
@@ -61,6 +65,9 @@ function newInstance(adapter) {
|
|
|
61
65
|
client: null,
|
|
62
66
|
running: false,
|
|
63
67
|
connected: false,
|
|
68
|
+
connectionState: 'disconnected', // 'connected' | 'reconnecting' | 'disconnected'; invariant: connected === (connectionState === 'connected')
|
|
69
|
+
startGen: 0, // generation counter; stale adapter listeners from a previous start are ignored
|
|
70
|
+
connPollTimer: null, // fallback poll timer for adapters exposing connectionProbe()
|
|
64
71
|
lastError: null,
|
|
65
72
|
bridgeDeps: null,
|
|
66
73
|
boundConversation: null,
|
|
@@ -88,9 +95,15 @@ function newInstance(adapter) {
|
|
|
88
95
|
* @property {Object} [capabilities] Informational flags ({inboundAck, sdkManagesToken}); not read by the core.
|
|
89
96
|
* @property {(cfg:Object)=>boolean} hasCreds True when cfg carries enough creds to connect.
|
|
90
97
|
* @property {(cfg:Object)=>Object} [statusFields] Extra non-secret status fields for the admin API.
|
|
91
|
-
* @property {(cfg:Object, hooks:{onInbound:(normalized:Object, ackCtx:*)=>void}, ctx:{fetch,store})=>Promise<*>} connect
|
|
98
|
+
* @property {(cfg:Object, hooks:{onInbound:(normalized:Object, ackCtx:*)=>void, onConnectionChange?:(state:?string, err?:*)=>void}, ctx:{fetch,store})=>Promise<*>} connect
|
|
92
99
|
* Open the long connection; return the live client. Call hooks.onInbound(normalized, ackCtx)
|
|
93
100
|
* per inbound message, where normalized = {text, conversationId, senderId, msgId, target}.
|
|
101
|
+
* Optionally call hooks.onConnectionChange?.(state, err) on transport lifecycle changes,
|
|
102
|
+
* where state ∈ 'connected'|'reconnecting'|'disconnected', or null to record err only.
|
|
103
|
+
* Always invoke it with optional chaining — callers (tests) may pass onInbound alone.
|
|
104
|
+
* @property {(client:*)=>('connected'|'reconnecting'|'disconnected')} [connectionProbe]
|
|
105
|
+
* For SDKs without usable lifecycle events: derive the live connection state from the
|
|
106
|
+
* client's fields. When present, the core polls it every CONN_POLL_MS while running.
|
|
94
107
|
* @property {(client:*, ctx:{fetch,store})=>Promise<void>} [disconnect] Tear down the client (best-effort).
|
|
95
108
|
* @property {(ackCtx:*, client:*)=>void} [ack] Ack an inbound msg (platforms that redeliver if not acked).
|
|
96
109
|
* @property {(cfg:Object, target:Object, content:string, ctx:{fetch,store})=>Promise<void>} sendOne Send one chunk.
|
|
@@ -320,6 +333,26 @@ function armActiveInjection(inst, target, since) {
|
|
|
320
333
|
}
|
|
321
334
|
}
|
|
322
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Transition the tri-state connection status. `gen` binds the caller to one startBridge lifetime,
|
|
338
|
+
* so a stale adapter listener firing after stopBridge/reloadBridge can never flip the state.
|
|
339
|
+
* state === null records the error only; identical consecutive states are no-ops (collapses
|
|
340
|
+
* SDK error/reconnect storms into single audited transitions).
|
|
341
|
+
*/
|
|
342
|
+
function setConnectionState(inst, gen, state, err) {
|
|
343
|
+
if (gen !== inst.startGen || !inst.running) return;
|
|
344
|
+
const errStr = err != null ? String(err?.message || err) : null;
|
|
345
|
+
if (errStr) inst.lastError = errStr;
|
|
346
|
+
if (state == null || state === inst.connectionState) return;
|
|
347
|
+
inst.connectionState = state;
|
|
348
|
+
inst.connected = state === 'connected';
|
|
349
|
+
// Recovery clears the retained disconnect cause — the UI ranks lastError above connected, so
|
|
350
|
+
// keeping it would leave the status stuck on "Error" after every transient drop. Deduped
|
|
351
|
+
// repeats and null-state calls don't clear, preserving send-failure diagnostics while healthy.
|
|
352
|
+
if (state === 'connected' && !errStr) inst.lastError = null;
|
|
353
|
+
audit(inst, 'connection', { state, ...(errStr ? { error: errStr } : {}) });
|
|
354
|
+
}
|
|
355
|
+
|
|
323
356
|
// ─── small helpers ───
|
|
324
357
|
function audit(inst, event, data) {
|
|
325
358
|
try {
|
|
@@ -711,21 +744,45 @@ export async function startBridge(id, deps) {
|
|
|
711
744
|
if (!inst.bridgeDeps || typeof inst.bridgeDeps.getConfig !== 'function') { audit(inst, 'start-skipped', { reason: 'no-deps' }); return; }
|
|
712
745
|
const cfg = inst.bridgeDeps.getConfig();
|
|
713
746
|
if (!cfg || !cfg.enabled || !inst.adapter.hasCreds(cfg)) return; // off / incomplete → no-op
|
|
747
|
+
// Bump AFTER the early-return guards: startBridge on an already-running instance is a legal
|
|
748
|
+
// no-op, and bumping there would orphan the live adapter's listeners (state frozen forever).
|
|
749
|
+
const gen = ++inst.startGen;
|
|
714
750
|
try {
|
|
715
|
-
const hooks = {
|
|
751
|
+
const hooks = {
|
|
752
|
+
onInbound: (normalized, ackCtx) => handleInbound(inst, normalized, ackCtx),
|
|
753
|
+
onConnectionChange: (state, err) => setConnectionState(inst, gen, state, err),
|
|
754
|
+
};
|
|
716
755
|
// Bound the connect so a hung adapter (e.g. Feishu WSClient.start() on a misconfigured app)
|
|
717
756
|
// becomes a lastError instead of blocking the whole startup chain.
|
|
718
|
-
|
|
757
|
+
const client = await Promise.race([
|
|
719
758
|
inst.adapter.connect(cfg, hooks, ctxFor(inst)),
|
|
720
759
|
new Promise((_, reject) => { const tm = setTimeout(() => reject(new Error('connect timeout')), CONNECT_TIMEOUT_MS); if (typeof tm.unref === 'function') tm.unref(); }),
|
|
721
760
|
]);
|
|
761
|
+
if (gen !== inst.startGen) {
|
|
762
|
+
// stopBridge / reload ran while the connect was in flight: this start lost the race. Don't
|
|
763
|
+
// resurrect the instance — tear down the late-arriving client so it doesn't leak.
|
|
764
|
+
try { await inst.adapter.disconnect?.(client, ctxFor(inst)); } catch { /* best-effort */ }
|
|
765
|
+
audit(inst, 'start-superseded', {});
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
inst.client = client;
|
|
722
769
|
inst.running = true;
|
|
723
770
|
inst.connected = true;
|
|
771
|
+
inst.connectionState = 'connected';
|
|
724
772
|
inst.lastError = null;
|
|
773
|
+
if (typeof inst.adapter.connectionProbe === 'function') {
|
|
774
|
+
inst.connPollTimer = setInterval(() => {
|
|
775
|
+
try { setConnectionState(inst, gen, inst.adapter.connectionProbe(inst.client), null); }
|
|
776
|
+
catch { /* best-effort: a probe throw must not kill the interval */ }
|
|
777
|
+
}, CONN_POLL_MS);
|
|
778
|
+
if (typeof inst.connPollTimer.unref === 'function') inst.connPollTimer.unref();
|
|
779
|
+
}
|
|
725
780
|
audit(inst, 'start', inst.adapter.statusFields ? inst.adapter.statusFields(cfg) : {});
|
|
726
781
|
} catch (e) {
|
|
782
|
+
if (gen !== inst.startGen) return; // superseded by stop/reload — don't overwrite their state
|
|
727
783
|
inst.lastError = String(e?.message || e);
|
|
728
784
|
inst.connected = false;
|
|
785
|
+
inst.connectionState = 'disconnected';
|
|
729
786
|
audit(inst, 'start-error', { error: inst.lastError });
|
|
730
787
|
}
|
|
731
788
|
}
|
|
@@ -733,6 +790,9 @@ export async function startBridge(id, deps) {
|
|
|
733
790
|
export async function stopBridge(id) {
|
|
734
791
|
const inst = instances.get(id);
|
|
735
792
|
if (!inst) return;
|
|
793
|
+
// Invalidate connection hooks up front so anything the adapter teardown fires can't flip state.
|
|
794
|
+
inst.startGen++;
|
|
795
|
+
if (inst.connPollTimer) { clearInterval(inst.connPollTimer); inst.connPollTimer = null; }
|
|
736
796
|
if (inst.ackCardPromise && activeInjection?.platformId === id) {
|
|
737
797
|
await finalizeAckCard(inst, activeInjection.target, tr(inst, 'noSession'), 'error');
|
|
738
798
|
} else {
|
|
@@ -743,6 +803,7 @@ export async function stopBridge(id) {
|
|
|
743
803
|
inst.client = null;
|
|
744
804
|
inst.running = false;
|
|
745
805
|
inst.connected = false;
|
|
806
|
+
inst.connectionState = 'disconnected';
|
|
746
807
|
inst.boundConversation = null;
|
|
747
808
|
if (activeInjection && activeInjection.platformId === id) clearActiveInjection();
|
|
748
809
|
inst.queue.length = 0;
|
|
@@ -760,10 +821,11 @@ export function isBridgeRunning(id) {
|
|
|
760
821
|
|
|
761
822
|
export function getBridgeStatus(id) {
|
|
762
823
|
const inst = instances.get(id);
|
|
763
|
-
if (!inst) return { running: false, connected: false, lastError: null, boundConversationId: null };
|
|
824
|
+
if (!inst) return { running: false, connected: false, connectionState: 'disconnected', lastError: null, boundConversationId: null };
|
|
764
825
|
const base = {
|
|
765
826
|
running: inst.running,
|
|
766
827
|
connected: inst.connected,
|
|
828
|
+
connectionState: inst.connectionState,
|
|
767
829
|
lastError: inst.lastError,
|
|
768
830
|
boundConversationId: inst.boundConversation?.conversationId || null,
|
|
769
831
|
};
|
|
@@ -799,6 +861,8 @@ export async function stopAll() {
|
|
|
799
861
|
// ─── test seams ───
|
|
800
862
|
export function __setStreamTickMsForTests(ms) { STREAM_TICK_MS = (ms == null ? 300 : ms); }
|
|
801
863
|
|
|
864
|
+
export function __setConnPollMsForTests(ms) { CONN_POLL_MS = (ms == null ? 5_000 : ms); }
|
|
865
|
+
|
|
802
866
|
export function __setMaxQueueForTests(id, n) {
|
|
803
867
|
const inst = instances.get(id);
|
|
804
868
|
if (inst) inst.maxQueueOverride = n;
|
|
@@ -809,6 +873,9 @@ export function __resetForTests(id) {
|
|
|
809
873
|
const inst = instances.get(id);
|
|
810
874
|
if (!inst) return;
|
|
811
875
|
inst.client = null; inst.running = false; inst.connected = false; inst.lastError = null;
|
|
876
|
+
inst.connectionState = 'disconnected';
|
|
877
|
+
inst.startGen++; // orphan any listeners still bound to a previous start
|
|
878
|
+
if (inst.connPollTimer) { clearInterval(inst.connPollTimer); inst.connPollTimer = null; }
|
|
812
879
|
inst.bridgeDeps = null; inst.boundConversation = null; inst.lastRepliedTurnTs = null;
|
|
813
880
|
inst.maxQueueOverride = null;
|
|
814
881
|
inst.seenMsgIds.length = 0; inst.queue.length = 0; inst.sendTimes.length = 0;
|
package/server/lib/im-lock.js
CHANGED
|
@@ -135,7 +135,15 @@ export function defaultProbe(id, port, { timeoutMs = 400 } = {}) {
|
|
|
135
135
|
const j = JSON.parse(body);
|
|
136
136
|
// 身份:能对「该 id」返回 IM status 形状的 loopback 服务即视为我们的 worker
|
|
137
137
|
if (j && (j.connection || typeof j.enabled === 'boolean')) {
|
|
138
|
-
finish({
|
|
138
|
+
finish({
|
|
139
|
+
ok: true,
|
|
140
|
+
connected: !!(j.connection && j.connection.connected),
|
|
141
|
+
// Old workers (pre-tri-state builds) omit connectionState → derive from connected.
|
|
142
|
+
connectionState: j.connection?.connectionState
|
|
143
|
+
?? (j.connection?.connected ? 'connected' : 'disconnected'),
|
|
144
|
+
lastError: j.connection?.lastError ?? null,
|
|
145
|
+
pid: j.pid,
|
|
146
|
+
});
|
|
139
147
|
} else finish(null);
|
|
140
148
|
} catch { finish(null); }
|
|
141
149
|
});
|
|
@@ -178,7 +186,13 @@ export async function getImLiveness(id, opts = {}) {
|
|
|
178
186
|
|
|
179
187
|
const res = await probe(id, lock.port);
|
|
180
188
|
if (res && res.ok && (res.pid == null || res.pid === lock.pid)) {
|
|
181
|
-
return {
|
|
189
|
+
return {
|
|
190
|
+
state: 'ready',
|
|
191
|
+
lock,
|
|
192
|
+
connected: !!res.connected,
|
|
193
|
+
connectionState: res.connectionState ?? (res.connected ? 'connected' : 'disconnected'),
|
|
194
|
+
lastError: res.lastError ?? null,
|
|
195
|
+
};
|
|
182
196
|
}
|
|
183
197
|
return { state: pidAlive(lock.pid) ? 'hung' : 'dead', lock };
|
|
184
198
|
}
|
|
@@ -139,7 +139,7 @@ export async function stopImProcess(id, opts = {}) {
|
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
141
|
* 查询某 IM 的进程状态(供 header chip / 路由)。
|
|
142
|
-
* @returns {Promise<{state:string, running:boolean, connected:boolean, pid:number|null, port:number|null, startedAt:string|null}>}
|
|
142
|
+
* @returns {Promise<{state:string, running:boolean, connected:boolean, connectionState:string, lastError:string|null, pid:number|null, port:number|null, startedAt:string|null}>}
|
|
143
143
|
*/
|
|
144
144
|
export async function getImProcessStatus(id, opts = {}) {
|
|
145
145
|
const live = await getImLiveness(id, opts);
|
|
@@ -147,6 +147,12 @@ export async function getImProcessStatus(id, opts = {}) {
|
|
|
147
147
|
state: live.state,
|
|
148
148
|
running: live.state === 'ready' || live.state === 'booting' || live.state === 'hung',
|
|
149
149
|
connected: live.state === 'ready' && !!live.connected,
|
|
150
|
+
// Tri-state IM link status from the worker's own report; a non-ready process cannot be
|
|
151
|
+
// connected or reconnecting, so it reads as disconnected.
|
|
152
|
+
connectionState: live.state === 'ready'
|
|
153
|
+
? (live.connectionState || (live.connected ? 'connected' : 'disconnected'))
|
|
154
|
+
: 'disconnected',
|
|
155
|
+
lastError: live.lastError ?? null,
|
|
150
156
|
pid: live.lock?.pid ?? null,
|
|
151
157
|
port: live.lock?.port ?? null,
|
|
152
158
|
startedAt: live.lock?.startedAt ?? null,
|
|
@@ -4,18 +4,22 @@ import { isNonEmptyFile } from './system-prompt-files.js';
|
|
|
4
4
|
|
|
5
5
|
// 「按模型定制 system prompt」的文件夹与文件名语法。
|
|
6
6
|
// 全局目录 <LOG_DIR>/system_prompt/ 与工作区目录 <workspace>/system_prompt/ 各放一套;
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// 启动时用「当前生效配置解析出的模型 id」(spawn-model-resolver.js:激活的三方 proxy profile
|
|
8
|
+
// 模型映射 > env > settings.json;无配置信号则不注入)做不区分大小写的子串匹配(条目名
|
|
9
|
+
// "OPUS" 命中 "claude-opus-4-8[1m]"),命中的文件整体取代工作区默认的 CC_SYSTEM.md /
|
|
10
|
+
// CC_APPEND_SYSTEM.md。
|
|
9
11
|
//
|
|
10
12
|
// Model-specific system prompt files. Two scopes share the same folder name
|
|
11
13
|
// (MODEL_PROMPT_DIR): global <LOG_DIR>/system_prompt/ and per-workspace
|
|
12
|
-
// <workspace>/system_prompt/. At spawn the model id from the
|
|
14
|
+
// <workspace>/system_prompt/. At spawn the model id resolved from the ACTIVE
|
|
15
|
+
// configuration (spawn-model-resolver.js — never from past usage records) is
|
|
13
16
|
// matched case-insensitively as a substring against entry names; a match fully
|
|
14
17
|
// replaces the workspace Default sentinels for that launch.
|
|
15
18
|
//
|
|
16
|
-
// Known limitations (
|
|
17
|
-
// -
|
|
18
|
-
//
|
|
19
|
+
// Known limitations (by design):
|
|
20
|
+
// - An entry named "haiku" only matches when haiku is explicitly configured as
|
|
21
|
+
// the main model (rare but legal); the old last-usage criterion filtered
|
|
22
|
+
// /haiku/i and could never match it.
|
|
19
23
|
// - Opening ~/.claude/cc-viewer itself as the workspace makes both scopes the
|
|
20
24
|
// same directory; harmless — the workspace scope short-circuits first.
|
|
21
25
|
export const MODEL_PROMPT_DIR = 'system_prompt';
|