cc-viewer 1.9.9 → 1.9.11
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/cli.js +4 -3
- package/dist/assets/App-D3swMyLc.js +2 -0
- package/dist/assets/{App-D06hBGII.css → App-DU5jmGrx.css} +1 -1
- package/dist/assets/{MdxEditorPanel-BJVD_bcU.js → MdxEditorPanel-DeyoQsxw.js} +1 -1
- package/dist/assets/Mobile-DvI_Kby2.js +1 -0
- package/dist/assets/ProxyStatsModal-CoI275by.js +1 -0
- package/dist/assets/{_baseUniq-CwQykUuc.js → _baseUniq-BvPjRimO.js} +1 -1
- package/dist/assets/{arc-EdUdBxtG.js → arc-BzZyqc6p.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DtBIdElY.js → architectureDiagram-Q4EWVU46-B1oszHzA.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-guNZIKkZ.js → blockDiagram-DXYQGD6D-Ndp4o_QB.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-xV5vB-Z9.js → c4Diagram-AHTNJAMY-DlDE-ALt.js} +1 -1
- package/dist/assets/{channel-DYEktNap.js → channel-BhXx9y6q.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-ZTkItpvj.js → chunk-4BX2VUAB-CM-2e6q_.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-dYIewyyR.js → chunk-4TB4RGXK-GWLkQKRO.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-CSsPNgPX.js → chunk-55IACEB6-B6LOFqRZ.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-CnMiuqzQ.js → chunk-EDXVE4YY-Ct3e09Ku.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-CVpATPZ3.js → chunk-FMBD7UC4-DK-vXpZP.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-Dpq4s8dW.js → chunk-OYMX7WX6-CWxZc_DU.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Bnojh6X6.js → chunk-QZHKN3VN-jp8ISHue.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-BYqK4Dsa.js → chunk-YZCP3GAM-DtYet6WV.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-C0dwAyuU.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-C0dwAyuU.js +1 -0
- package/dist/assets/clone-fiofFlrV.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-OJ7mmWyV.js → cose-bilkent-S5V4N54A-lNC-slVX.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-B1Kwd_Rk.js → dagre-KV5264BT-vm6U82qq.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-D8qrw8sp.js → diagram-5BDNPKRD-Dq3FXh-U.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-ip-N4ho8.js → diagram-G4DWMVQ6-BxF85wcF.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-2PZaskUF.js → diagram-MMDJMWI5-CFCTeMz0.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-CeAOQUZP.js → diagram-TYMM5635-Bui5Azdg.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BCBaEYEb.js → erDiagram-SMLLAGMA-DrpUO1Kb.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CsGyIqcX.js → flowDiagram-DWJPFMVM-Bb3amxiK.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-COsj3FZ-.js → ganttDiagram-T4ZO3ILL-BRwCFM3C.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DvQVQGJG.js → gitGraphDiagram-UUTBAWPF-DVXbBwhs.js} +1 -1
- package/dist/assets/{graph-BSB9fDSP.js → graph-Dh22wYWs.js} +1 -1
- package/dist/assets/index-BRW8dfBU.js +2 -0
- package/dist/assets/{index-BRw4Iz2O.js → index-BwZB7UwN.js} +1 -1
- package/dist/assets/{index-eM2ih5n7.js → index-CQ1Nswa3.js} +1 -1
- package/dist/assets/{index-BuVComcY.js → index-CfFFgxxf.js} +1 -1
- package/dist/assets/{index-DKufsAzx.js → index-Cfm4N4ao.js} +1 -1
- package/dist/assets/{index-CNNAYjkF.js → index-UlO7Q5dX.js} +1 -1
- package/dist/assets/{index-DXBfwPYD.js → index-e8wejtj_.js} +1 -1
- package/dist/assets/{index-Bo-12bug.js → index-xZXOwglE.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-eNucS4iW.js → infoDiagram-42DDH7IO-cQPbF7Q6.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js → ishikawaDiagram-UXIWVN3A-cp-ir90Y.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-yJJzVEBb.js → journeyDiagram-VCZTEJTY-aoMcyNLC.js} +1 -1
- package/dist/assets/{jszip.min-ByI0ds5u.js → jszip.min-C7BBsGL5.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-2rfTi2zw.js → kanban-definition-6JOO6SKY-Cugr0o0f.js} +1 -1
- package/dist/assets/{layout-DTmHj0Wm.js → layout-CL0eiocE.js} +1 -1
- package/dist/assets/{linear-BOA3a9FE.js → linear-D6EJvwNs.js} +1 -1
- package/dist/assets/{mermaid.core-5H-NR79H.js → mermaid.core-CNYcCP19.js} +2 -2
- package/dist/assets/{min-CpjFTEec.js → min-dFBWKmZ_.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-B8KCjFgB.js → mindmap-definition-QFDTVHPH-4fbr7E2n.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-BARy96g5.js → pieDiagram-DEJITSTG-UHWzU_JW.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DYoTapuI.js → quadrantDiagram-34T5L4WZ-e87bZkAb.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-EeANLLK2.js → requirementDiagram-MS252O5E-l_97YNSi.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DmQ-YVPj.js → sankeyDiagram-XADWPNL6-D3JFnltO.js} +1 -1
- package/dist/assets/seqResourceLoaders-8kfGbCYo.js +2 -0
- package/dist/assets/seqResourceLoaders-C2YGihCH.css +41 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-SZtW0rYC.js → sequenceDiagram-FGHM5R23-5jDKcsRZ.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CEygsIld.js → stateDiagram-FHFEXIEX-D5_gBKn8.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js → stateDiagram-v2-QKLJ7IA2-BP5uQ5EH.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-DhPBaQBt.js → timeline-definition-GMOUNBTQ-7IVQRwK1.js} +1 -1
- package/dist/assets/vendor-antd-CfuxfYfy.js +17 -0
- package/dist/assets/vendor-codemirror-C4Jn4TTc.js +1 -0
- package/dist/assets/{vendor-mdxeditor-Cz09a1_I.js → vendor-mdxeditor-BreKt5Hd.js} +2 -2
- package/dist/assets/{vendor-qrcode-qbbntD6-.js → vendor-qrcode-CutTqXgh.js} +1 -1
- package/dist/assets/{vendor-virtuoso-Sk8fQ4Hx.js → vendor-virtuoso-Cc9ovCDf.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-NniTHxDw.js → vennDiagram-DHZGUBPP-D6qzGquN.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-D9SNOrDw.js → wardley-RL74JXVD-DzAFqZmY.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DvHXKNza.js → wardleyDiagram-NUSXRM2D-CImP9kv8.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CaiwPTQB.js → xychartDiagram-5P7HB3ND-Co45rRaM.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/plugins/README.md +9 -1
- package/server/interceptor.js +1 -1
- package/server/lib/builtin-model-prompts.js +1 -1
- package/server/lib/chat-queue.js +1 -1
- package/server/lib/credential-access.js +1 -1
- package/server/lib/credential-migrate.js +1 -1
- package/server/lib/error-report.js +26 -0
- package/server/lib/json-store.js +1 -1
- package/server/lib/launch-config.js +1 -1
- package/server/lib/log-watcher.js +1 -1
- package/server/lib/plugin-host.mjs +198 -0
- package/server/lib/plugin-loader.js +346 -90
- package/server/lib/plugin-manager.js +46 -2
- package/server/lib/plugin-manifest.js +329 -0
- package/server/lib/plugin-panels.js +183 -0
- package/server/lib/plugin-proxy.js +408 -0
- package/server/lib/plugin-runtime.js +775 -0
- package/server/lib/plugin-service-manager.js +463 -0
- package/server/lib/proxy/proxy-retry.js +1 -1
- package/server/lib/proxy/proxy-stats.js +1 -1
- package/server/lib/resume-watchdog.js +1 -1
- package/server/lib/same-origin.js +13 -3
- package/server/lib/sdk-manager.js +16 -9
- package/server/lib/sdk-session-index.js +1 -1
- package/server/lib/stats-worker.js +1 -1
- package/server/lib/system-prompt-files.js +1 -1
- package/server/lib/system-prompt-live.js +1 -1
- package/server/lib/system-prompt-snapshots.js +1 -1
- package/server/lib/v2/adapter.js +1 -1
- package/server/lib/v2/convert.js +1 -1
- package/server/lib/v2/jsonl-read.js +1 -1
- package/server/lib/v2/live-feed.js +1 -1
- package/server/lib/v2/meta-rows.js +1 -1
- package/server/lib/v2/resume-list.js +1 -1
- package/server/lib/v2/session-list.js +1 -1
- package/server/lib/v2/session-owner.js +1 -1
- package/server/lib/v2/v2-writer.js +1 -1
- package/server/lib/wire-compress.js +1 -1
- package/server/lib/ws-mux.js +1 -1
- package/server/proxy.js +1 -1
- package/server/pty-manager.js +1 -1
- package/server/routes/ask-perm.js +1 -1
- package/server/routes/events.js +1 -1
- package/server/routes/expert.js +1 -1
- package/server/routes/files-content.js +1 -1
- package/server/routes/files-fs.js +4 -2
- package/server/routes/misc.js +11 -0
- package/server/routes/plugin-services.js +104 -0
- package/server/routes/plugins.js +145 -17
- package/server/routes/preferences.js +1 -1
- package/server/routes/resume.js +1 -1
- package/server/routes/sdk-context.js +1 -1
- package/server/routes/sdk-mcp-status.js +1 -1
- package/server/routes/workspaces.js +1 -1
- package/server/server.js +328 -171
- package/dist/assets/App-CLteNB83.js +0 -2
- package/dist/assets/Mobile-CJCQf3MQ.js +0 -1
- package/dist/assets/ProxyStatsModal-B5ccoOPy.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +0 -1
- package/dist/assets/clone-CFj55h49.js +0 -1
- package/dist/assets/index-CqX5TkEL.js +0 -2
- package/dist/assets/seqResourceLoaders-7lsgbvGa.js +0 -2
- package/dist/assets/seqResourceLoaders-C0cU2e1o.css +0 -41
- package/dist/assets/vendor-antd-CS_S2mBN.js +0 -17
- package/dist/assets/vendor-codemirror-B5l-VbBO.js +0 -1
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// Service-plugin child-process management (parent side).
|
|
2
|
+
// Spawns one detached child per (server process × service plugin): the child runs the
|
|
3
|
+
// plugin's service.mjs as its own HTTP server on 127.0.0.1:0, writes a lock file with
|
|
4
|
+
// its port, and the parent reverse-proxies /plugins/<name>/* to it (see plugin-proxy.js).
|
|
5
|
+
//
|
|
6
|
+
// Modeled on lib/im/im-process-manager.js: detached process group (group kill on stop),
|
|
7
|
+
// env prefix-stripping, lock-file handshake, liveness probing, test guard. Key
|
|
8
|
+
// differences: locks are per-server-PID (Electron runs several server processes over the
|
|
9
|
+
// same LOG_DIR, each gets its own plugin instance), identity includes a per-spawn nonce
|
|
10
|
+
// (prevents adopting a previous generation's child), and stale-lock cleanup verifies
|
|
11
|
+
// health identity before killing (a reused PID may belong to an unrelated process).
|
|
12
|
+
import { spawn as nodeSpawn, execSync, spawnSync } from 'node:child_process';
|
|
13
|
+
import { openSync, closeSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync } from 'node:fs';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { reportSwallowed } from './error-report.js';
|
|
18
|
+
|
|
19
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
|
+
|
|
21
|
+
const PLUGIN_HOST_PATH = join(dirname(fileURLToPath(import.meta.url)), 'plugin-host.mjs');
|
|
22
|
+
|
|
23
|
+
/** Resolve the real node binary (under Electron, process.execPath is the Electron binary). */
|
|
24
|
+
export function resolveNodeBinary() {
|
|
25
|
+
if (!process.versions.electron) return process.execPath;
|
|
26
|
+
try {
|
|
27
|
+
const out = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8', windowsHide: true });
|
|
28
|
+
const p = process.platform === 'win32' ? out.split('\n')[0].trim() : out.trim();
|
|
29
|
+
if (p) return p;
|
|
30
|
+
} catch { /* fall through */ }
|
|
31
|
+
// #129: packaged GUI environments may lack a shell PATH; fall back to Electron itself + ELECTRON_RUN_AS_NODE
|
|
32
|
+
return null; // null -> spawn with process.execPath + ELECTRON_RUN_AS_NODE=1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function serviceRuntimeDir(logDir, name) {
|
|
36
|
+
return join(logDir, 'plugins-runtime', name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function serviceDataDir(logDir, name) {
|
|
40
|
+
return join(logDir, 'plugin-data', name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function serviceLockPath(runtimeDir, serverPid) {
|
|
44
|
+
return join(runtimeDir, `service-${serverPid}.lock`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function serviceConfigPath(runtimeDir, serverPid, generation) {
|
|
48
|
+
return join(runtimeDir, `config-${serverPid}-${generation}.json`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function serviceLogPath(runtimeDir, serverPid) {
|
|
52
|
+
return join(runtimeDir, `process-${serverPid}.log`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Inherited environment is an allowlist, not a denylist: ANTHROPIC_* / CCV_* secrets
|
|
56
|
+
// (auth tokens, passwords) must never reach a plugin child. /proc/<pid>/environ is
|
|
57
|
+
// readable by the same user, and argv/env leaks are not worth saving a config file read.
|
|
58
|
+
const CHILD_ENV_ALLOWLIST = new Set([
|
|
59
|
+
'PATH', 'HOME', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
60
|
+
'SYSTEMROOT', 'COMSPEC', 'PATHEXT', 'TERM', 'SHELL', 'USER', 'LOGNAME',
|
|
61
|
+
'HOSTNAME', 'NODE_ENV',
|
|
62
|
+
// Windows user-profile dirs: Node's os.homedir() throws without USERPROFILE, and many
|
|
63
|
+
// runtimes write caches under APPDATA/LOCALAPPDATA. None of these carry secrets.
|
|
64
|
+
'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA',
|
|
65
|
+
'PROGRAMDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build the service child environment from an explicit allowlist.
|
|
70
|
+
* Inherits only OS basics plus LC_* and CCV_PLUGIN_*; manifest.service.env is injected
|
|
71
|
+
* explicitly below. The plugin secret/nonce live only in the 0600 config file.
|
|
72
|
+
*/
|
|
73
|
+
export function buildChildEnv({ name, version, pluginDir, configPath, lockPath, runtimeDir, dataDir, parentPort, parentBasePath, manifestEnv = {}, base = process.env }) {
|
|
74
|
+
const env = {};
|
|
75
|
+
for (const [k, v] of Object.entries(base)) {
|
|
76
|
+
const upper = k.toUpperCase();
|
|
77
|
+
if (CHILD_ENV_ALLOWLIST.has(upper) || upper.startsWith('LC_') || k.startsWith('CCV_PLUGIN_')) {
|
|
78
|
+
env[k] = v;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
env.CCV_PLUGIN_NAME = name;
|
|
82
|
+
if (version) env.CCV_PLUGIN_VERSION = version;
|
|
83
|
+
env.CCV_PLUGIN_DIR = pluginDir;
|
|
84
|
+
env.CCV_PLUGIN_CONFIG = configPath;
|
|
85
|
+
env.CCV_PLUGIN_HOST = pathToFileURL(PLUGIN_HOST_PATH).href; // file:// URL so Windows can import it too
|
|
86
|
+
env.CCV_PLUGIN_LOCK = lockPath;
|
|
87
|
+
env.CCV_PLUGIN_RUNTIME_DIR = runtimeDir;
|
|
88
|
+
env.CCV_PLUGIN_DATA_DIR = dataDir;
|
|
89
|
+
env.CCV_PLUGIN_PARENT_PORT = String(parentPort || '');
|
|
90
|
+
env.CCV_PLUGIN_PARENT_BASE_PATH = parentBasePath || '';
|
|
91
|
+
for (const [k, v] of Object.entries(manifestEnv)) {
|
|
92
|
+
if (!k.startsWith('CCV_') && !k.startsWith('CCVIEWER_')) env[k] = v;
|
|
93
|
+
}
|
|
94
|
+
return env;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Spawn one service child. Returns { pid, child, lockPath, configPath, outLog, secret, nonce }
|
|
99
|
+
* or { blockedByTestGuard: true } (test guard).
|
|
100
|
+
*
|
|
101
|
+
* @param {object} desc { name, version, entryPath, pluginDir, prefix, env }
|
|
102
|
+
* @param {object} ctx { logDir, parentPort, parentBasePath, generation, serverPid, bootNonce }
|
|
103
|
+
* @param {object} opts { spawnImpl?, nodeBinary? } test injection points
|
|
104
|
+
*/
|
|
105
|
+
export function spawnServiceProcess(desc, ctx, opts = {}) {
|
|
106
|
+
const runtimeDir = serviceRuntimeDir(ctx.logDir, desc.name);
|
|
107
|
+
const serverPid = ctx.serverPid ?? process.pid;
|
|
108
|
+
// ████████ test isolation guard (modeled on im-process-manager L4) ████████
|
|
109
|
+
if (process.env.NODE_TEST_CONTEXT && !opts.spawnImpl && process.env.CCV_TEST_ALLOW_PLUGIN_SPAWN !== '1') {
|
|
110
|
+
return { blockedByTestGuard: true, runtimeDir, pid: undefined };
|
|
111
|
+
}
|
|
112
|
+
// ████████████████████████████████████████████████████████████
|
|
113
|
+
const spawnImpl = opts.spawnImpl || nodeSpawn;
|
|
114
|
+
const dataDir = serviceDataDir(ctx.logDir, desc.name);
|
|
115
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
116
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
117
|
+
|
|
118
|
+
const generation = ctx.generation ?? 1;
|
|
119
|
+
const lockPath = serviceLockPath(runtimeDir, serverPid);
|
|
120
|
+
const configPath = serviceConfigPath(runtimeDir, serverPid, generation);
|
|
121
|
+
const outLog = serviceLogPath(runtimeDir, serverPid);
|
|
122
|
+
const secret = randomBytes(24).toString('hex');
|
|
123
|
+
const nonce = randomBytes(16).toString('hex');
|
|
124
|
+
|
|
125
|
+
// 0600 config: the plugin secret/nonce live only here (atomic write). The parent
|
|
126
|
+
// ACCESS_TOKEN is deliberately absent — loopback parent APIs need no token, and a
|
|
127
|
+
// secret sitting in config (or a URL query) is needless exposure.
|
|
128
|
+
const configBody = {
|
|
129
|
+
name: desc.name,
|
|
130
|
+
version: desc.version ?? null,
|
|
131
|
+
prefix: desc.prefix,
|
|
132
|
+
pluginDir: desc.pluginDir,
|
|
133
|
+
dataDir,
|
|
134
|
+
runtimeDir,
|
|
135
|
+
lockPath,
|
|
136
|
+
secret,
|
|
137
|
+
nonce,
|
|
138
|
+
generation,
|
|
139
|
+
parentPid: serverPid,
|
|
140
|
+
bootNonce: ctx.bootNonce ?? null, // lets cleanup detect a stale same-PID/different-boot lock
|
|
141
|
+
parentPort: ctx.parentPort ?? null,
|
|
142
|
+
parentBasePath: ctx.parentBasePath ?? '',
|
|
143
|
+
};
|
|
144
|
+
try {
|
|
145
|
+
const tmpPath = `${configPath}.tmp-${process.pid}`;
|
|
146
|
+
writeFileSync(tmpPath, JSON.stringify(configBody), { mode: 0o600 });
|
|
147
|
+
renameSync(tmpPath, configPath);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
reportSwallowed('plugin-service.config-write', err);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let stdio = 'ignore';
|
|
153
|
+
let fd = null;
|
|
154
|
+
try {
|
|
155
|
+
fd = openSync(outLog, 'a');
|
|
156
|
+
stdio = ['ignore', fd, fd];
|
|
157
|
+
} catch { /* fall back to ignore when the log file cannot be opened */ }
|
|
158
|
+
|
|
159
|
+
const nodeBinary = opts.nodeBinary !== undefined ? opts.nodeBinary : resolveNodeBinary();
|
|
160
|
+
const spawnEnv = buildChildEnv({
|
|
161
|
+
name: desc.name,
|
|
162
|
+
version: desc.version,
|
|
163
|
+
pluginDir: desc.pluginDir,
|
|
164
|
+
configPath,
|
|
165
|
+
lockPath,
|
|
166
|
+
runtimeDir,
|
|
167
|
+
dataDir,
|
|
168
|
+
parentPort: ctx.parentPort,
|
|
169
|
+
parentBasePath: ctx.parentBasePath,
|
|
170
|
+
manifestEnv: desc.env,
|
|
171
|
+
});
|
|
172
|
+
let executable = nodeBinary || process.execPath;
|
|
173
|
+
if (!nodeBinary && process.versions.electron) {
|
|
174
|
+
// fall back to Electron itself as the node runtime
|
|
175
|
+
spawnEnv.ELECTRON_RUN_AS_NODE = '1';
|
|
176
|
+
} else {
|
|
177
|
+
delete spawnEnv.ELECTRON_RUN_AS_NODE;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const child = spawnImpl(executable, [desc.entryPath], {
|
|
181
|
+
cwd: desc.pluginDir,
|
|
182
|
+
env: spawnEnv,
|
|
183
|
+
stdio,
|
|
184
|
+
detached: true,
|
|
185
|
+
windowsHide: true,
|
|
186
|
+
});
|
|
187
|
+
// No unref: the parent must keep the event loop referenced while the child lives (detached
|
|
188
|
+
// does not mean unsupervised; stopServiceProcess/process-group kill owns cleanup). unref
|
|
189
|
+
// would let the parent exit while the child lives, and some runtimes harvest detached+unref children instantly (fake code=0 exit).
|
|
190
|
+
if (fd !== null) { try { closeSync(fd); } catch { /* child already duped it */ } }
|
|
191
|
+
return { pid: child?.pid, child, lockPath, configPath, outLog, secret, nonce, runtimeDir, dataDir };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function readServiceLock(lockPath) {
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function isPidAlive(pid) {
|
|
203
|
+
if (!pid || typeof pid !== 'number') return false;
|
|
204
|
+
try {
|
|
205
|
+
process.kill(pid, 0);
|
|
206
|
+
return true;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return err && err.code === 'EPERM';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Wait for the child to become ready: poll lock (pid+name) -> GET /__ccv/health (secret + nonce).
|
|
214
|
+
* @returns {{ ok:true, port, pid, lock } | { ok:false, error, state }}
|
|
215
|
+
*/
|
|
216
|
+
export async function waitForServiceReady(desc, opts = {}) {
|
|
217
|
+
const lockPath = opts.lockPath;
|
|
218
|
+
const secret = opts.secret;
|
|
219
|
+
const nonce = opts.nonce;
|
|
220
|
+
const expectedPid = opts.expectedPid;
|
|
221
|
+
const timeoutMs = opts.timeoutMs ?? 10000;
|
|
222
|
+
const pollMs = opts.pollMs ?? 50;
|
|
223
|
+
const probe = opts.probeImpl || probeServiceHealth;
|
|
224
|
+
const deadline = Date.now() + timeoutMs;
|
|
225
|
+
|
|
226
|
+
while (Date.now() < deadline) {
|
|
227
|
+
const lock = readServiceLock(lockPath);
|
|
228
|
+
if (lock && lock.pid && lock.port && lock.name === desc.name) {
|
|
229
|
+
if (expectedPid && lock.pid !== expectedPid) {
|
|
230
|
+
// lock overwritten by another process/previous generation - keep waiting for ours
|
|
231
|
+
} else {
|
|
232
|
+
const health = await probe(lock.port, secret, { timeoutMs: 400 });
|
|
233
|
+
if (health.ok && health.body && health.body.ok === true && health.body.name === desc.name) {
|
|
234
|
+
if (nonce && health.body.nonce !== nonce) {
|
|
235
|
+
return { ok: false, error: 'health nonce mismatch (stale or foreign service)', state: 'foreign' };
|
|
236
|
+
}
|
|
237
|
+
if (health.body.pid && health.body.pid !== lock.pid) {
|
|
238
|
+
return { ok: false, error: 'health pid mismatch', state: 'foreign' };
|
|
239
|
+
}
|
|
240
|
+
return { ok: true, port: lock.port, pid: lock.pid, lock };
|
|
241
|
+
}
|
|
242
|
+
if (!health.ok && health.state === 'foreign') {
|
|
243
|
+
return { ok: false, error: 'health identity mismatch', state: 'foreign' };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (opts.isChildDead && opts.isChildDead()) {
|
|
248
|
+
return { ok: false, error: 'child exited before becoming ready', state: 'dead' };
|
|
249
|
+
}
|
|
250
|
+
await sleep(pollMs);
|
|
251
|
+
}
|
|
252
|
+
return { ok: false, error: `service did not become ready within ${timeoutMs}ms`, state: 'timeout' };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* GET http://127.0.0.1:<port>/__ccv/health with secret header.
|
|
257
|
+
* x-cc-viewer-internal:1 lets the interceptor's fetch patch pass through (otherwise loopback
|
|
258
|
+
* requests are rewritten as API traffic to ANTHROPIC_BASE_URL - 2026-10-22 field incident:
|
|
259
|
+
* in CLI mode every health probe hit the upstream and the service never became ready).
|
|
260
|
+
* @returns {Promise<{ok:boolean, state?:string, body?:object}>}
|
|
261
|
+
*/
|
|
262
|
+
export async function probeServiceHealth(port, secret, opts = {}) {
|
|
263
|
+
const timeoutMs = opts.timeoutMs ?? 400;
|
|
264
|
+
try {
|
|
265
|
+
const controller = new AbortController();
|
|
266
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
267
|
+
timer.unref?.();
|
|
268
|
+
const res = await fetch(`http://127.0.0.1:${port}/__ccv/health`, {
|
|
269
|
+
headers: { 'x-ccv-plugin-secret': secret, 'x-cc-viewer-internal': '1' },
|
|
270
|
+
signal: controller.signal,
|
|
271
|
+
});
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
const body = await res.json().catch(() => null);
|
|
274
|
+
if (res.status === 401 || res.status === 403) return { ok: false, state: 'foreign', body };
|
|
275
|
+
return { ok: res.ok, body };
|
|
276
|
+
} catch (err) {
|
|
277
|
+
return { ok: false, error: err.message };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Default process-group kill (detached group leader); Windows uses taskkill /T /F; falls back to the single process. */
|
|
282
|
+
export function defaultKill(pid, signal) {
|
|
283
|
+
if (process.platform === 'win32' && signal === 'SIGKILL') {
|
|
284
|
+
try {
|
|
285
|
+
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, timeout: 1000 });
|
|
286
|
+
return;
|
|
287
|
+
} catch { /* fall through */ }
|
|
288
|
+
}
|
|
289
|
+
try { process.kill(-pid, signal); return; } catch { /* not a group leader / windows */ }
|
|
290
|
+
try { process.kill(pid, signal); } catch { /* already gone */ }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Stop a service child: SIGTERM → poll for exit → SIGKILL on timeout → remove lock/config.
|
|
295
|
+
*
|
|
296
|
+
* A PID alone proves nothing — a crashed child's PID can be reused by an unrelated
|
|
297
|
+
* process, and killing it would be catastrophic. Signal only when the lock file confirms
|
|
298
|
+
* this exact PID (and, when both sides carry one, the same per-spawn nonce). Otherwise
|
|
299
|
+
* remove the stale files and leave the process alone.
|
|
300
|
+
*/
|
|
301
|
+
export async function stopServiceProcess(name, opts = {}) {
|
|
302
|
+
const lockPath = opts.lockPath;
|
|
303
|
+
const configPath = opts.configPath;
|
|
304
|
+
const kill = opts.killImpl || defaultKill;
|
|
305
|
+
const isAlive = opts.isAlive || isPidAlive;
|
|
306
|
+
const timeoutMs = opts.timeoutMs ?? 2000;
|
|
307
|
+
const pollMs = opts.pollMs ?? 100;
|
|
308
|
+
|
|
309
|
+
const lock = lockPath ? readServiceLock(lockPath) : null;
|
|
310
|
+
const pid = opts.pid ?? lock?.pid;
|
|
311
|
+
const removeFiles = () => {
|
|
312
|
+
for (const p of [lockPath, configPath]) {
|
|
313
|
+
if (p) { try { unlinkSync(p); } catch { /* already gone */ } }
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const lockMatchesPid = !!lock && !!pid && lock.pid === pid;
|
|
317
|
+
const nonceMatches = !opts.nonce || !lock?.nonce || lock.nonce === opts.nonce;
|
|
318
|
+
if (!pid || !lockMatchesPid || !nonceMatches || !isAlive(pid)) {
|
|
319
|
+
removeFiles();
|
|
320
|
+
return { stopped: true, alreadyDead: !isAlive(pid), skippedSignal: !lockMatchesPid || !nonceMatches };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
kill(pid, 'SIGTERM');
|
|
324
|
+
const deadline = Date.now() + timeoutMs;
|
|
325
|
+
while (Date.now() < deadline) {
|
|
326
|
+
await sleep(pollMs);
|
|
327
|
+
if (!isAlive(pid)) {
|
|
328
|
+
removeFiles();
|
|
329
|
+
return { stopped: true };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
kill(pid, 'SIGKILL');
|
|
333
|
+
await sleep(pollMs);
|
|
334
|
+
removeFiles();
|
|
335
|
+
return { stopped: true, forced: true };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Clean stale locks at startup:
|
|
340
|
+
* - parentPid dead: verify health identity first (kill only on nonce/pid match - the PID may be reused);
|
|
341
|
+
* - parentPid === this process but bootNonce differs: same-PID reboot; the old child is orphaned, treat as above;
|
|
342
|
+
* - parentPid alive and not this process: another cc-viewer instance's plugin, keep it.
|
|
343
|
+
*
|
|
344
|
+
* @param {string} logDir
|
|
345
|
+
* @param {{ bootNonce:string, isAlive?:Function, killImpl?:Function }} opts
|
|
346
|
+
*/
|
|
347
|
+
export async function cleanupStaleServiceLocks(logDir, opts = {}) {
|
|
348
|
+
const bootNonce = opts.bootNonce;
|
|
349
|
+
const isAlive = opts.isAlive || isPidAlive;
|
|
350
|
+
const kill = opts.killImpl || defaultKill;
|
|
351
|
+
const runtimeRoot = join(logDir, 'plugins-runtime');
|
|
352
|
+
let pluginDirs;
|
|
353
|
+
try {
|
|
354
|
+
pluginDirs = readdirSync(runtimeRoot, { withFileTypes: true });
|
|
355
|
+
} catch {
|
|
356
|
+
return { cleaned: 0, kept: 0 };
|
|
357
|
+
}
|
|
358
|
+
let cleaned = 0;
|
|
359
|
+
let kept = 0;
|
|
360
|
+
for (const dirent of pluginDirs) {
|
|
361
|
+
if (!dirent.isDirectory()) continue;
|
|
362
|
+
const dir = join(runtimeRoot, dirent.name);
|
|
363
|
+
let lockFiles;
|
|
364
|
+
try {
|
|
365
|
+
lockFiles = readdirSync(dir).filter(f => f.startsWith('service-') && f.endsWith('.lock'));
|
|
366
|
+
} catch {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
for (const lockFile of lockFiles) {
|
|
370
|
+
const lockPath = join(dir, lockFile);
|
|
371
|
+
const lock = readServiceLock(lockPath);
|
|
372
|
+
if (!lock || !lock.pid) {
|
|
373
|
+
try { unlinkSync(lockPath); } catch { /* noop */ }
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const parentAlive = lock.parentPid && isAlive(lock.parentPid);
|
|
377
|
+
const ownParent = lock.parentPid === process.pid;
|
|
378
|
+
const bootMismatch = ownParent && bootNonce && lock.bootNonce && lock.bootNonce !== bootNonce;
|
|
379
|
+
if (parentAlive && !bootMismatch) {
|
|
380
|
+
kept++;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
// parent dead or boot mismatch: verify the child's identity before killing
|
|
384
|
+
if (isAlive(lock.pid)) {
|
|
385
|
+
let identityConfirmed = false;
|
|
386
|
+
try {
|
|
387
|
+
// pick the config with the same serverPid and highest numeric generation (lexical order puts -10 before -9)
|
|
388
|
+
const lockServerPid = lockFile.slice('service-'.length, -'.lock'.length);
|
|
389
|
+
const configFiles = readdirSync(dir)
|
|
390
|
+
.filter(f => f.startsWith(`config-${lockServerPid}-`) && f.endsWith('.json'))
|
|
391
|
+
.sort((a, b) => {
|
|
392
|
+
const ga = Number(a.slice(`config-${lockServerPid}-`.length, -'.json'.length)) || 0;
|
|
393
|
+
const gb = Number(b.slice(`config-${lockServerPid}-`.length, -'.json'.length)) || 0;
|
|
394
|
+
return ga - gb;
|
|
395
|
+
});
|
|
396
|
+
const configPath = configFiles.length ? join(dir, configFiles[configFiles.length - 1]) : null;
|
|
397
|
+
const config = configPath ? JSON.parse(readFileSync(configPath, 'utf-8')) : null;
|
|
398
|
+
if (config && config.secret && lock.port) {
|
|
399
|
+
const health = await probeServiceHealth(lock.port, config.secret, { timeoutMs: 400 });
|
|
400
|
+
if (health.ok && health.body && health.body.pid === lock.pid) {
|
|
401
|
+
identityConfirmed = true;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch (err) {
|
|
405
|
+
reportSwallowed('plugin-service.stale-probe', err);
|
|
406
|
+
}
|
|
407
|
+
if (!identityConfirmed) {
|
|
408
|
+
// identity unconfirmed - the PID may be unrelated; clear the lock without killing
|
|
409
|
+
kept++;
|
|
410
|
+
try { unlinkSync(lockPath); } catch { /* noop */ }
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
kill(lock.pid, 'SIGTERM');
|
|
414
|
+
await sleep(300);
|
|
415
|
+
if (isAlive(lock.pid)) kill(lock.pid, 'SIGKILL');
|
|
416
|
+
}
|
|
417
|
+
try { unlinkSync(lockPath); } catch { /* noop */ }
|
|
418
|
+
// also clear configs for older generations of this serverPid (aborted startups and
|
|
419
|
+
// previous generations can leave them behind; crash cleanup removes the current one)
|
|
420
|
+
try {
|
|
421
|
+
const lockServerPid = lockFile.slice('service-'.length, -'.lock'.length);
|
|
422
|
+
for (const f of readdirSync(dir)) {
|
|
423
|
+
if (f.startsWith(`config-${lockServerPid}-`) && f.endsWith('.json')) {
|
|
424
|
+
try { unlinkSync(join(dir, f)); } catch { /* noop */ }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} catch { /* noop */ }
|
|
428
|
+
cleaned++;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { cleaned, kept };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Restart backoff: at most 5 restarts per 60s window; the counter resets after 60s of stable uptime.
|
|
436
|
+
*/
|
|
437
|
+
export function createRestartBackoff(opts = {}) {
|
|
438
|
+
const delays = opts.delays || [0, 500, 1000, 2000, 5000];
|
|
439
|
+
const windowMs = opts.windowMs ?? 60000;
|
|
440
|
+
const stableMs = opts.stableMs ?? 60000;
|
|
441
|
+
let crashes = [];
|
|
442
|
+
let lastStartAt = 0;
|
|
443
|
+
return {
|
|
444
|
+
markStarted() {
|
|
445
|
+
const now = Date.now();
|
|
446
|
+
if (lastStartAt && now - lastStartAt >= stableMs) crashes = [];
|
|
447
|
+
lastStartAt = now;
|
|
448
|
+
},
|
|
449
|
+
/** @returns {{ allowed:boolean, delayMs:number }} */
|
|
450
|
+
next() {
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
crashes = crashes.filter(t => now - t < windowMs);
|
|
453
|
+
if (crashes.length >= delays.length) return { allowed: false, delayMs: 0 };
|
|
454
|
+
const delayMs = delays[crashes.length];
|
|
455
|
+
crashes.push(now);
|
|
456
|
+
return { allowed: true, delayMs };
|
|
457
|
+
},
|
|
458
|
+
reset() { crashes = []; },
|
|
459
|
+
get crashCount() { return crashes.length; },
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export { PLUGIN_HOST_PATH };
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// - race/stagger use AbortController; cancelled requests must be released correctly.
|
|
18
18
|
import { resolveProfileModel, replaceTopLevelSystem } from '../interceptor-core.js';
|
|
19
19
|
import { readFileSync, existsSync } from 'node:fs';
|
|
20
|
-
import { reportSwallowed } from '
|
|
20
|
+
import { reportSwallowed } from '../../lib/error-report.js';
|
|
21
21
|
import { liveSystemPromptEnabled, getLiveEntry, applyLiveSystem, knownInjectedTexts } from '../system-prompt-live.js';
|
|
22
22
|
import { parseUserId } from '../session-id.js';
|
|
23
23
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { mkdirSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { AsyncWriteQueue } from '../async-write-queue.js';
|
|
12
|
-
import { reportSwallowed } from '
|
|
12
|
+
import { reportSwallowed } from '../../lib/error-report.js';
|
|
13
13
|
|
|
14
14
|
// ── Detail record schema ──────────────────────────────────────────────
|
|
15
15
|
// Appends one JSON line after each proxied LLM API request completes. Fields align with llm-retry-proxy's retry records.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// affects the request or spawn paths.
|
|
13
13
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
-
import { reportSwallowed } from '
|
|
15
|
+
import { reportSwallowed } from '../lib/error-report.js';
|
|
16
16
|
import { LOG_DIR } from '../../findcc.js';
|
|
17
17
|
import { injectionConfigured } from './launch-config.js';
|
|
18
18
|
import { readSnapshotByKey, transcriptDirForCwd, projectKeyForCwd } from './system-prompt-snapshots.js';
|
|
@@ -9,13 +9,23 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Canonical implementation — every call site imports from here.
|
|
11
11
|
export function isSameOriginBrowserRequest(req, parsedUrl) {
|
|
12
|
+
// Sec-Fetch-Site is set by browsers even on Origin-less subresource/no-cors GETs;
|
|
13
|
+
// check it first so a cross-site page cannot slip through the no-Origin CLI path.
|
|
14
|
+
if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
|
|
12
15
|
const origin = req.headers?.origin;
|
|
13
16
|
if (!origin) return true; // CLI/native clients do not send Origin.
|
|
14
|
-
if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
|
|
15
17
|
try {
|
|
16
18
|
const originUrl = new URL(origin);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
// Host is the invariant (already allowlist-guarded). Scheme can differ legitimately
|
|
20
|
+
// when a TLS-terminating reverse proxy fronts the server (browser sees https, the
|
|
21
|
+
// backend still parses http) — so compare host, and only when the proxy explicitly
|
|
22
|
+
// forwards the scheme (X-Forwarded-Proto) do we also require it to match.
|
|
23
|
+
const originHost = originUrl.host.toLowerCase();
|
|
24
|
+
const reqHost = String(parsedUrl?.host || req.headers?.host || '').toLowerCase();
|
|
25
|
+
if (originHost !== reqHost) return false;
|
|
26
|
+
const xfp = req.headers?.['x-forwarded-proto'];
|
|
27
|
+
if (xfp && originUrl.protocol.replace(':', '').toLowerCase() !== String(xfp).toLowerCase()) return false;
|
|
28
|
+
return true;
|
|
19
29
|
} catch {
|
|
20
30
|
return false;
|
|
21
31
|
}
|
|
@@ -30,7 +30,7 @@ import { ASK_TIMEOUT_MS } from './ask/ask-constants.js';
|
|
|
30
30
|
import { withDefaultThinkingDisplay, resolveLaunchSystemPrompt, launchArgsToExtraArgs } from './launch-config.js';
|
|
31
31
|
import { evaluateImDeny } from './im-deny.js';
|
|
32
32
|
import { APPROVAL_TOOLS, isOverTrustDir, isPublishCommand } from './approval-policy.js';
|
|
33
|
-
import { reportSwallowed } from '
|
|
33
|
+
import { reportSwallowed } from '../lib/error-report.js';
|
|
34
34
|
import { resolve as _pathResolve, dirname as _pathDirname, sep as _pathSep } from 'node:path';
|
|
35
35
|
import { createHash } from 'node:crypto';
|
|
36
36
|
import { registerSdkSessionLister } from './sdk-session-index.js';
|
|
@@ -147,7 +147,7 @@ const MAX_QUEUE_ITEMS = 50;
|
|
|
147
147
|
// Callbacks registered by cli.js (one wiring for the whole process; every session
|
|
148
148
|
// reuses the same callbacks — they broadcast to the shared terminal WS).
|
|
149
149
|
let _onTurnEnd = null; // SDK mode has no Stop hook (ensureHooks() skipped) — fire turnEnd directly when the SDK 'result' message arrives
|
|
150
|
-
let _onQueryError = null; // query-level failure → (message: string) => void; cli.js forwards
|
|
150
|
+
let _onQueryError = null; // query-level failure → (message: string, meta: {cwd, sessionId, instanceKey}) => void; cli.js forwards it as a scoped sdk-error WS frame (dialogue row + toast)
|
|
151
151
|
let _broadcastWs = null;
|
|
152
152
|
let _runWaterfallHook = null;
|
|
153
153
|
|
|
@@ -710,10 +710,12 @@ function _createSession(key, cwd) {
|
|
|
710
710
|
}
|
|
711
711
|
}
|
|
712
712
|
|
|
713
|
-
/**
|
|
713
|
+
/** Query-level error signal; the routing envelope always comes from this
|
|
714
|
+
* session's closure (callback throw must never break the caller). */
|
|
714
715
|
function _notifyQueryError(err) {
|
|
715
716
|
if (!_onQueryError) return;
|
|
716
|
-
|
|
717
|
+
const context = { cwd: _cwd, sessionId: _sessionId || null, instanceKey };
|
|
718
|
+
try { _onQueryError(String(err?.message || err), context); }
|
|
717
719
|
catch (cbErr) { console.warn('[sdk-manager] onQueryError threw:', cbErr?.message); }
|
|
718
720
|
}
|
|
719
721
|
|
|
@@ -883,7 +885,8 @@ function _createSession(key, cwd) {
|
|
|
883
885
|
|
|
884
886
|
/**
|
|
885
887
|
* Surface a failed turn to the Web UI — the wire path persists the failed entry but
|
|
886
|
-
* never
|
|
888
|
+
* never signals it, so a dead/errored turn would otherwise look like a hang. The
|
|
889
|
+
* sdk-error frame renders as a persistent dialogue row plus a toast client-side.
|
|
887
890
|
*/
|
|
888
891
|
function _notifyTurnError(resultMsg) {
|
|
889
892
|
const isError = resultMsg.is_error === true || (resultMsg.subtype && resultMsg.subtype !== 'success');
|
|
@@ -1674,9 +1677,9 @@ export function isSdkAvailable() {
|
|
|
1674
1677
|
* @param {string} projectName
|
|
1675
1678
|
* @param {object} deps
|
|
1676
1679
|
* @param {function} deps.onTurnEnd — ({sessionId, ts}) => void, fired on SDK 'result'
|
|
1677
|
-
* @param {function} [deps.onQueryError] — (message: string) => void,
|
|
1678
|
-
* query itself fails (spawn/protocol/iterator errors)
|
|
1679
|
-
*
|
|
1680
|
+
* @param {function} [deps.onQueryError] — (message: string, meta: {cwd, sessionId, instanceKey}) => void,
|
|
1681
|
+
* fired when the query itself fails (spawn/protocol/iterator errors); meta carries the session's
|
|
1682
|
+
* routing envelope so the sdk-error frame scopes to the owning workspace's viewers
|
|
1680
1683
|
* @param {function} deps.broadcastWs — (msg) => void, terminal-WS broadcast for approvals
|
|
1681
1684
|
* @param {string} [deps.permissionMode]
|
|
1682
1685
|
* @param {function} [deps.runWaterfallHook] — plugin waterfall (onPlanRequest/onAskRequest/onPermRequest)
|
|
@@ -1727,7 +1730,11 @@ export async function sendUserMessage(text, anchor) {
|
|
|
1727
1730
|
console.warn(`[sdk-manager] no SDK session registered for "${targetName}" — message dropped`);
|
|
1728
1731
|
reportSwallowed('sdk-manager.route', new Error(`no SDK session for ${targetName}`));
|
|
1729
1732
|
if (_onQueryError) {
|
|
1730
|
-
|
|
1733
|
+
// Module-level fallback has no session closure. Anchors never carry cwd
|
|
1734
|
+
// (server.js builds {project, instanceKey} only) — pass the project key so
|
|
1735
|
+
// broadcastWsMessage can scope the frame; instanceKey narrows further when present.
|
|
1736
|
+
const meta = { cwd: anchor.cwd || null, sessionId: null, instanceKey: anchor.instanceKey || null, project: anchor.project || null };
|
|
1737
|
+
try { _onQueryError(`Workspace "${targetName}" is no longer available — message not sent.`, meta); }
|
|
1731
1738
|
catch (err) { console.warn('[sdk-manager] onQueryError threw:', err?.message); }
|
|
1732
1739
|
}
|
|
1733
1740
|
return;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Synchronous read-only projection of the SDK session registry. Generic view
|
|
2
2
|
// routing must not import sdk-manager (and eagerly load the Agent SDK) in PTY mode.
|
|
3
|
-
import { reportSwallowed } from '
|
|
3
|
+
import { reportSwallowed } from '../lib/error-report.js';
|
|
4
4
|
|
|
5
5
|
let _listSessions = null;
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ import { join, basename } from 'node:path';
|
|
|
10
10
|
import { readJsonlTolerant, listSessionIds } from './v2/replay.js';
|
|
11
11
|
import { dirSizeSync } from './v2/layout.js';
|
|
12
12
|
import { isDiscardableSession } from './v2/session-select.js';
|
|
13
|
-
import { reportSwallowed } from '
|
|
13
|
+
import { reportSwallowed } from '../lib/error-report.js';
|
|
14
14
|
import {
|
|
15
15
|
INTER_SESSION_TYPES, isSystemText, extractUserTexts, isSuggestionMode,
|
|
16
16
|
collectPromptsFromEvents, sortEpochFiles,
|
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { MODEL_PROMPT_DIR, matchModelPrompt } from './model-system-prompts.js';
|
|
4
4
|
import { isBuiltinDisabled, matchBuiltinModelPrompt, materializeBuiltinPrompt } from './builtin-model-prompts.js';
|
|
5
5
|
import { isNonEmptyFile } from './file-api.js';
|
|
6
|
-
import { reportSwallowed } from '
|
|
6
|
+
import { reportSwallowed } from '../lib/error-report.js';
|
|
7
7
|
|
|
8
8
|
// isNonEmptyFile lives in file-api.js (shared leaf, cycle break:
|
|
9
9
|
// model-system-prompts.js must not import this module). Re-exported here so
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from 'node:fs';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { randomBytes } from 'node:crypto';
|
|
22
|
-
import { reportSwallowed } from '
|
|
22
|
+
import { reportSwallowed } from '../lib/error-report.js';
|
|
23
23
|
import { LOG_DIR } from '../../findcc.js';
|
|
24
24
|
import { renameSyncWithRetry } from './file-api.js';
|
|
25
25
|
import { matchModelPrompt, MODEL_PROMPT_DIR, readModelPrompt } from './model-system-prompts.js';
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
|
|
34
34
|
import { join, basename, dirname } from 'node:path';
|
|
35
35
|
import { randomBytes } from 'node:crypto';
|
|
36
|
-
import { reportSwallowed } from '
|
|
36
|
+
import { reportSwallowed } from '../lib/error-report.js';
|
|
37
37
|
import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
|
|
38
38
|
import { renameSyncWithRetry } from './file-api.js';
|
|
39
39
|
|
package/server/lib/v2/adapter.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
31
31
|
import { join, dirname, basename } from 'node:path';
|
|
32
|
-
import { reportSwallowed } from '
|
|
32
|
+
import { reportSwallowed } from '../../lib/error-report.js';
|
|
33
33
|
import { isMainAgentRequest } from '../interceptor-core.js';
|
|
34
34
|
import { readSession } from './replay.js';
|
|
35
35
|
import { iterateJsonlLines } from './jsonl-read.js';
|
package/server/lib/v2/convert.js
CHANGED
|
@@ -28,7 +28,7 @@ import { join, basename } from 'node:path';
|
|
|
28
28
|
import { iterateRawEntriesAsync } from '../log-stream.js';
|
|
29
29
|
import { createIncrementalReconstructor } from '@ccv/core/delta-reconstructor';
|
|
30
30
|
import { isLogFileName, parseLogTs } from '../log-file-utils.js';
|
|
31
|
-
import { reportSwallowed } from '
|
|
31
|
+
import { reportSwallowed } from '../../lib/error-report.js';
|
|
32
32
|
import { V2Writer } from './v2-writer.js';
|
|
33
33
|
import { verifyV1File } from './verify.js';
|
|
34
34
|
import { parseUserId } from './identity.js';
|