u1s1-cli 1.3.0 → 1.3.1
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/dist/api.d.ts +6 -0
- package/dist/api.js +123 -21
- package/dist/bench.js +23 -6
- package/dist/deploy.d.ts +6 -0
- package/dist/deploy.js +41 -9
- package/dist/device-auth.d.ts +3 -0
- package/dist/device-auth.js +69 -27
- package/dist/index.js +7 -3
- package/dist/login.js +49 -15
- package/dist/model.d.ts +6 -0
- package/dist/model.js +24 -5
- package/dist/search-tools.js +15 -2
- package/dist/subagent.d.ts +14 -0
- package/dist/subagent.js +47 -11
- package/dist/tools.d.ts +4 -0
- package/dist/tools.js +84 -16
- package/dist/update.js +19 -7
- package/dist/workflow/runner.d.ts +17 -0
- package/dist/workflow/runner.js +6 -7
- package/dist/workflow/templates.js +4 -4
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { type ApiThinkingCapabilities, type CliConfig } from "./config.js";
|
|
2
|
+
/** Preserve caller cancellation while always enforcing the operation deadline. */
|
|
3
|
+
export declare function signalWithTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal;
|
|
4
|
+
/** Read a complete response without allowing a custom or faulty server to exhaust CLI memory. */
|
|
5
|
+
export declare function readResponseTextCapped(resp: Response, maxBytes?: number): Promise<string>;
|
|
6
|
+
/** Parse JSON only after the complete response has passed the shared byte cap. */
|
|
7
|
+
export declare function readJsonResponseCapped<T = unknown>(resp: Response, maxBytes?: number): Promise<T>;
|
|
2
8
|
export interface MeResponse {
|
|
3
9
|
email: string | null;
|
|
4
10
|
signup_credit_usd?: number;
|
package/dist/api.js
CHANGED
|
@@ -1,12 +1,67 @@
|
|
|
1
1
|
import { apiEndpointToCustom, loadEndpointsCache, saveEndpointsCache, setCustomEndpoints, VERSION, } from "./config.js";
|
|
2
2
|
import { authorizedFetch } from "./device-auth.js";
|
|
3
|
+
const MAX_API_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
const MAX_API_ERROR_BYTES = 64 * 1024;
|
|
5
|
+
const MAX_IMAGE_RESULT_BYTES = 64 * 1024;
|
|
6
|
+
function jsonRecord(value) {
|
|
7
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: null;
|
|
10
|
+
}
|
|
11
|
+
/** Preserve caller cancellation while always enforcing the operation deadline. */
|
|
12
|
+
export function signalWithTimeout(signal, timeoutMs) {
|
|
13
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
14
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
15
|
+
}
|
|
16
|
+
/** Read a complete response without allowing a custom or faulty server to exhaust CLI memory. */
|
|
17
|
+
export async function readResponseTextCapped(resp, maxBytes = MAX_API_RESPONSE_BYTES) {
|
|
18
|
+
const limit = Math.max(1, Math.floor(maxBytes));
|
|
19
|
+
const declaredLength = Number(resp.headers.get("content-length"));
|
|
20
|
+
if (Number.isFinite(declaredLength) && declaredLength > limit) {
|
|
21
|
+
void resp.body?.cancel("API response body limit reached").catch(() => { });
|
|
22
|
+
throw new Error(`API response body exceeds ${limit} bytes`);
|
|
23
|
+
}
|
|
24
|
+
if (!resp.body)
|
|
25
|
+
throw new Error("API response body is missing");
|
|
26
|
+
const reader = resp.body.getReader();
|
|
27
|
+
const decoder = new TextDecoder();
|
|
28
|
+
let bytes = 0;
|
|
29
|
+
let text = "";
|
|
30
|
+
try {
|
|
31
|
+
for (;;) {
|
|
32
|
+
const { done, value } = await reader.read();
|
|
33
|
+
if (done) {
|
|
34
|
+
text += decoder.decode();
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
if (bytes + value.byteLength > limit) {
|
|
38
|
+
void reader.cancel("API response body limit reached").catch(() => { });
|
|
39
|
+
throw new Error(`API response body exceeds ${limit} bytes`);
|
|
40
|
+
}
|
|
41
|
+
bytes += value.byteLength;
|
|
42
|
+
text += decoder.decode(value, { stream: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
try {
|
|
47
|
+
reader.releaseLock();
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
}
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
/** Parse JSON only after the complete response has passed the shared byte cap. */
|
|
54
|
+
export async function readJsonResponseCapped(resp, maxBytes = MAX_API_RESPONSE_BYTES) {
|
|
55
|
+
return JSON.parse(await readResponseTextCapped(resp, maxBytes));
|
|
56
|
+
}
|
|
3
57
|
/** 401:凭证失效(设备被移除/换过钥匙),调用方应引导重新登录而不是继续硬跑。 */
|
|
4
58
|
export class AuthError extends Error {
|
|
5
59
|
}
|
|
6
60
|
/** 网关错误壳统一是 { error: { message } };解析不出来时回退到状态码提示。 */
|
|
7
61
|
async function errorMessageFromResponse(resp, fallback) {
|
|
8
|
-
const body = (await resp
|
|
9
|
-
|
|
62
|
+
const body = jsonRecord(await readJsonResponseCapped(resp, MAX_API_ERROR_BYTES).catch(() => null));
|
|
63
|
+
const error = jsonRecord(body?.error);
|
|
64
|
+
return typeof error?.message === "string" ? error.message.slice(0, 2_000) : fallback;
|
|
10
65
|
}
|
|
11
66
|
export async function fetchModels(cfg) {
|
|
12
67
|
let resp;
|
|
@@ -23,7 +78,9 @@ export async function fetchModels(cfg) {
|
|
|
23
78
|
throw new AuthError("登录已失效,请重新运行 u1s1 login");
|
|
24
79
|
if (!resp.ok)
|
|
25
80
|
throw new Error(await errorMessageFromResponse(resp, `服务端返回 ${resp.status},稍后再试`));
|
|
26
|
-
const body =
|
|
81
|
+
const body = await readJsonResponseCapped(resp);
|
|
82
|
+
if (!body || !Array.isArray(body.data))
|
|
83
|
+
throw new Error("服务端模型列表格式不正确");
|
|
27
84
|
const token = body.client_attestation?.token;
|
|
28
85
|
return {
|
|
29
86
|
models: body.data,
|
|
@@ -49,8 +106,8 @@ export async function fetchUserEndpoints(cfg) {
|
|
|
49
106
|
}
|
|
50
107
|
if (!resp.ok)
|
|
51
108
|
throw new Error(await errorMessageFromResponse(resp, `服务端返回 ${resp.status}`));
|
|
52
|
-
const body = (await resp
|
|
53
|
-
return Array.isArray(body
|
|
109
|
+
const body = jsonRecord(await readJsonResponseCapped(resp));
|
|
110
|
+
return Array.isArray(body?.endpoints) ? body.endpoints : [];
|
|
54
111
|
}
|
|
55
112
|
/**
|
|
56
113
|
* 拉取云端配置的自定义端点并装载进 CUSTOM_ENDPOINTS;失败(离线/老网关)回退
|
|
@@ -79,17 +136,37 @@ export async function searchWeb(cfg, input) {
|
|
|
79
136
|
"content-type": "application/json",
|
|
80
137
|
},
|
|
81
138
|
body: JSON.stringify({ query: input.query, max_results: input.maxResults }),
|
|
82
|
-
signal: input.signal
|
|
139
|
+
signal: signalWithTimeout(input.signal, 60_000),
|
|
83
140
|
});
|
|
84
141
|
}
|
|
85
|
-
catch {
|
|
86
|
-
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (input.signal?.aborted)
|
|
144
|
+
throw error;
|
|
145
|
+
throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`, { cause: error });
|
|
87
146
|
}
|
|
88
147
|
if (resp.status === 401)
|
|
89
148
|
throw new Error("登录已失效,请重新运行 u1s1 login");
|
|
90
149
|
if (!resp.ok)
|
|
91
150
|
throw new Error(await errorMessageFromResponse(resp, `搜索服务返回 ${resp.status},稍后再试`));
|
|
92
|
-
|
|
151
|
+
const body = jsonRecord(await readJsonResponseCapped(resp));
|
|
152
|
+
if (!body || !Array.isArray(body.results))
|
|
153
|
+
throw new Error("搜索服务返回格式不正确");
|
|
154
|
+
const results = [];
|
|
155
|
+
for (const value of body.results.slice(0, 10)) {
|
|
156
|
+
const result = jsonRecord(value);
|
|
157
|
+
if (!result
|
|
158
|
+
|| typeof result.title !== "string"
|
|
159
|
+
|| typeof result.url !== "string"
|
|
160
|
+
|| typeof result.snippet !== "string") {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
results.push({ title: result.title, url: result.url, snippet: result.snippet });
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
query: typeof body.query === "string" ? body.query : input.query,
|
|
167
|
+
answer: typeof body.answer === "string" ? body.answer : null,
|
|
168
|
+
results,
|
|
169
|
+
};
|
|
93
170
|
}
|
|
94
171
|
/** web_fetch 直连失败时的回退:网关用 Cloudflare Browser Rendering 渲染后转 markdown。 */
|
|
95
172
|
export async function renderPage(cfg, url, signal) {
|
|
@@ -104,31 +181,40 @@ export async function renderPage(cfg, url, signal) {
|
|
|
104
181
|
"content-type": "application/json",
|
|
105
182
|
},
|
|
106
183
|
body: JSON.stringify({ url }),
|
|
107
|
-
signal: signal
|
|
184
|
+
signal: signalWithTimeout(signal, 90_000),
|
|
108
185
|
});
|
|
109
186
|
}
|
|
110
|
-
catch {
|
|
111
|
-
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (signal?.aborted)
|
|
189
|
+
throw error;
|
|
190
|
+
throw new Error(`连不上 ${cfg.baseUrl}`, { cause: error });
|
|
112
191
|
}
|
|
113
192
|
if (resp.status === 401)
|
|
114
193
|
throw new Error("登录已失效,请重新运行 u1s1 login");
|
|
115
194
|
if (!resp.ok)
|
|
116
195
|
throw new Error(await errorMessageFromResponse(resp, `渲染服务返回 ${resp.status}`));
|
|
117
|
-
|
|
196
|
+
const body = jsonRecord(await readJsonResponseCapped(resp));
|
|
197
|
+
if (!body || typeof body.url !== "string" || typeof body.markdown !== "string") {
|
|
198
|
+
throw new Error("渲染服务返回格式不正确");
|
|
199
|
+
}
|
|
200
|
+
return { url: body.url, markdown: body.markdown };
|
|
118
201
|
}
|
|
119
202
|
const IMAGE_URL_HEADER = "x-u1s1-image-url";
|
|
120
203
|
const IMAGE_SIZE_HEADER = "x-u1s1-image-size";
|
|
121
204
|
function imageResultFromHeaders(resp) {
|
|
122
205
|
const encodedUrl = resp.headers.get(IMAGE_URL_HEADER);
|
|
123
|
-
if (!encodedUrl)
|
|
206
|
+
if (!encodedUrl || encodedUrl.length > 8_000)
|
|
124
207
|
return null;
|
|
125
208
|
try {
|
|
126
209
|
const url = decodeURIComponent(encodedUrl);
|
|
210
|
+
if (/[\u0000-\u001f\u007f]/.test(url))
|
|
211
|
+
return null;
|
|
127
212
|
const parsed = new URL(url);
|
|
128
213
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
129
214
|
return null;
|
|
130
215
|
const encodedSize = resp.headers.get(IMAGE_SIZE_HEADER);
|
|
131
|
-
|
|
216
|
+
const size = encodedSize ? decodeURIComponent(encodedSize) : null;
|
|
217
|
+
return { url, size: size && size.length <= 100 ? size : null };
|
|
132
218
|
}
|
|
133
219
|
catch {
|
|
134
220
|
return null;
|
|
@@ -147,15 +233,28 @@ function responseErrorDetail(error) {
|
|
|
147
233
|
export async function readGeneratedImageResponse(resp) {
|
|
148
234
|
const headerResult = imageResultFromHeaders(resp);
|
|
149
235
|
if (headerResult) {
|
|
150
|
-
|
|
236
|
+
void resp.body?.cancel("image result recovered from response headers").catch(() => undefined);
|
|
151
237
|
return headerResult;
|
|
152
238
|
}
|
|
153
239
|
try {
|
|
154
|
-
const body = (await resp
|
|
155
|
-
if (typeof body
|
|
240
|
+
const body = jsonRecord(await readJsonResponseCapped(resp, MAX_IMAGE_RESULT_BYTES));
|
|
241
|
+
if (typeof body?.url !== "string") {
|
|
242
|
+
throw new Error("服务端没有返回图片下载地址");
|
|
243
|
+
}
|
|
244
|
+
let parsed;
|
|
245
|
+
try {
|
|
246
|
+
parsed = new URL(body.url);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
throw new Error("服务端没有返回图片下载地址");
|
|
250
|
+
}
|
|
251
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
156
252
|
throw new Error("服务端没有返回图片下载地址");
|
|
157
253
|
}
|
|
158
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
url: body.url,
|
|
256
|
+
size: typeof body.size === "string" && body.size.length <= 100 ? body.size : null,
|
|
257
|
+
};
|
|
159
258
|
}
|
|
160
259
|
catch (error) {
|
|
161
260
|
throw new Error(`图片可能已经生成,但客户端下载结果响应失败: ${responseErrorDetail(error)}。` +
|
|
@@ -175,7 +274,7 @@ export async function generateImage(cfg, req, signal) {
|
|
|
175
274
|
"content-type": "application/json",
|
|
176
275
|
},
|
|
177
276
|
body: JSON.stringify(req),
|
|
178
|
-
signal: signal
|
|
277
|
+
signal: signalWithTimeout(signal, 180_000),
|
|
179
278
|
});
|
|
180
279
|
}
|
|
181
280
|
catch (error) {
|
|
@@ -206,5 +305,8 @@ export async function fetchMe(cfg) {
|
|
|
206
305
|
throw new Error("登录已失效,请重新运行 u1s1 login");
|
|
207
306
|
if (!resp.ok)
|
|
208
307
|
throw new Error(await errorMessageFromResponse(resp, `服务端返回 ${resp.status},稍后再试`));
|
|
209
|
-
|
|
308
|
+
const body = await readJsonResponseCapped(resp);
|
|
309
|
+
if (!body || typeof body !== "object")
|
|
310
|
+
throw new Error("服务端账户响应格式不正确");
|
|
311
|
+
return body;
|
|
210
312
|
}
|
package/dist/bench.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join, dirname } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { CUSTOM_ENDPOINTS, loadConfig, MODELS, PROVIDER_ID, } from "./config.js";
|
|
6
|
-
import { loadCustomEndpoints } from "./api.js";
|
|
6
|
+
import { loadCustomEndpoints, readJsonResponseCapped, readResponseTextCapped } from "./api.js";
|
|
7
7
|
import { authorizedFetch, hasDeviceCredential } from "./device-auth.js";
|
|
8
8
|
import { costStr, errorRate, formatDuration, generateReport, renderTable, scoreBar, scoreRate, sortByScore, summarizeByModel, tokPerSec, } from "./bench-report.js";
|
|
9
9
|
import { scoreResponse } from "./bench-scoring.js";
|
|
@@ -108,15 +108,32 @@ async function callModel(input) {
|
|
|
108
108
|
: await fetch(`${baseUrl}/chat/completions`, request);
|
|
109
109
|
const latencyMs = Math.round(performance.now() - start);
|
|
110
110
|
if (!res.ok) {
|
|
111
|
-
const errBody = await res
|
|
111
|
+
const errBody = await readResponseTextCapped(res, 64 * 1024).catch(() => "未知错误");
|
|
112
112
|
return { response: "", latencyMs, tokensIn: 0, tokensOut: 0, error: `HTTP ${res.status}: ${errBody.slice(0, 200)}` };
|
|
113
113
|
}
|
|
114
|
-
const
|
|
114
|
+
const value = await readJsonResponseCapped(res);
|
|
115
|
+
const data = value && typeof value === "object" && !Array.isArray(value)
|
|
116
|
+
? value
|
|
117
|
+
: null;
|
|
118
|
+
const choices = Array.isArray(data?.choices) ? data.choices : [];
|
|
119
|
+
const firstChoice = choices[0] && typeof choices[0] === "object" && !Array.isArray(choices[0])
|
|
120
|
+
? choices[0]
|
|
121
|
+
: null;
|
|
122
|
+
const message = firstChoice?.message && typeof firstChoice.message === "object" && !Array.isArray(firstChoice.message)
|
|
123
|
+
? firstChoice.message
|
|
124
|
+
: null;
|
|
125
|
+
const usage = data?.usage && typeof data.usage === "object" && !Array.isArray(data.usage)
|
|
126
|
+
? data.usage
|
|
127
|
+
: null;
|
|
115
128
|
return {
|
|
116
|
-
response:
|
|
129
|
+
response: typeof message?.content === "string" ? message.content : "",
|
|
117
130
|
latencyMs,
|
|
118
|
-
tokensIn:
|
|
119
|
-
|
|
131
|
+
tokensIn: typeof usage?.prompt_tokens === "number" && Number.isFinite(usage.prompt_tokens)
|
|
132
|
+
? Math.max(0, usage.prompt_tokens)
|
|
133
|
+
: 0,
|
|
134
|
+
tokensOut: typeof usage?.completion_tokens === "number" && Number.isFinite(usage.completion_tokens)
|
|
135
|
+
? Math.max(0, usage.completion_tokens)
|
|
136
|
+
: 0,
|
|
120
137
|
};
|
|
121
138
|
}
|
|
122
139
|
catch (e) {
|
package/dist/deploy.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { type CliConfig } from "./config.js";
|
|
2
|
+
interface SiteFile {
|
|
3
|
+
path: string;
|
|
4
|
+
abs: string;
|
|
5
|
+
bytes: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function collectFiles(root: string): SiteFile[];
|
|
2
8
|
type SiteVisibility = "public" | "private";
|
|
3
9
|
interface DeployArgs {
|
|
4
10
|
name?: string;
|
package/dist/deploy.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { VERSION, u1s1Dir } from "./config.js";
|
|
5
5
|
import { authorizedFetch } from "./device-auth.js";
|
|
6
|
+
import { readJsonResponseCapped } from "./api.js";
|
|
6
7
|
/**
|
|
7
8
|
* u1s1 deploy publishes a static site to <project>.<account-code>.u1s1.app.
|
|
8
9
|
* Detect the site root, ask for a project slug, and remember it in ~/.u1s1/deploys.json.
|
|
@@ -12,6 +13,8 @@ const deploysFile = join(u1s1Dir, "deploys.json");
|
|
|
12
13
|
/** 构建产物目录优先:Vite/Next 等项目根的 index.html 是源码,不是能直接上线的产物。 */
|
|
13
14
|
const BUILD_DIRS = ["dist", "build", "out", "_site", "public"];
|
|
14
15
|
const SKIP_DIRS = new Set(["node_modules", "__pycache__"]);
|
|
16
|
+
const MAX_DEPLOY_API_RESPONSE_BYTES = 256 * 1024;
|
|
17
|
+
const MAX_DEPLOY_ERROR_RESPONSE_BYTES = 64 * 1024;
|
|
15
18
|
function readDeploys() {
|
|
16
19
|
try {
|
|
17
20
|
return JSON.parse(readFileSync(deploysFile, "utf8"));
|
|
@@ -56,14 +59,18 @@ function resolveSiteDir(explicit) {
|
|
|
56
59
|
" 在网站目录里运行 u1s1 deploy,或指定目录:u1s1 deploy <目录>\n" +
|
|
57
60
|
" 如果项目需要构建(如 Vite/Next),先跑构建再部署 dist/ 等产物目录");
|
|
58
61
|
}
|
|
59
|
-
function collectFiles(root) {
|
|
62
|
+
export function collectFiles(root) {
|
|
60
63
|
const files = [];
|
|
61
64
|
const walk = (dir) => {
|
|
62
65
|
for (const name of readdirSync(dir)) {
|
|
63
66
|
if (name.startsWith(".") || SKIP_DIRS.has(name))
|
|
64
67
|
continue;
|
|
65
68
|
const abs = join(dir, name);
|
|
66
|
-
const st =
|
|
69
|
+
const st = lstatSync(abs);
|
|
70
|
+
// Never upload or recurse through links: a project-controlled link could
|
|
71
|
+
// escape the selected root, expose credentials, or create a directory cycle.
|
|
72
|
+
if (st.isSymbolicLink())
|
|
73
|
+
continue;
|
|
67
74
|
if (st.isDirectory())
|
|
68
75
|
walk(abs);
|
|
69
76
|
else if (st.isFile()) {
|
|
@@ -136,12 +143,25 @@ async function api(cfg, request) {
|
|
|
136
143
|
catch {
|
|
137
144
|
throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
|
|
138
145
|
}
|
|
139
|
-
const
|
|
146
|
+
const value = await readJsonResponseCapped(resp, MAX_DEPLOY_API_RESPONSE_BYTES).catch(() => null);
|
|
147
|
+
const data = value && typeof value === "object" && !Array.isArray(value)
|
|
148
|
+
? value
|
|
149
|
+
: null;
|
|
140
150
|
if (!resp.ok) {
|
|
141
|
-
const
|
|
142
|
-
|
|
151
|
+
const detail = data?.error && typeof data.error === "object"
|
|
152
|
+
? data.error
|
|
153
|
+
: undefined;
|
|
154
|
+
const message = typeof detail?.message === "string"
|
|
155
|
+
? detail.message.slice(0, 2_000)
|
|
156
|
+
: `服务端返回 ${resp.status},稍后再试`;
|
|
157
|
+
const e = new Error(message);
|
|
158
|
+
if (typeof detail?.code === "string" && detail.code.length <= 100) {
|
|
159
|
+
e.code = detail.code;
|
|
160
|
+
}
|
|
143
161
|
throw e;
|
|
144
162
|
}
|
|
163
|
+
if (!data)
|
|
164
|
+
throw new Error("服务端部署响应格式不正确");
|
|
145
165
|
return data;
|
|
146
166
|
}
|
|
147
167
|
/** 逐个上传,失败重试一次;并发数保守取 6。 */
|
|
@@ -157,12 +177,24 @@ async function uploadAll(cfg, start, files) {
|
|
|
157
177
|
signal: AbortSignal.timeout(120_000),
|
|
158
178
|
});
|
|
159
179
|
let resp = await put().catch(() => null);
|
|
160
|
-
if (!resp
|
|
180
|
+
if (resp && !resp.ok) {
|
|
181
|
+
void resp.body?.cancel("retrying rejected deploy upload").catch(() => { });
|
|
161
182
|
resp = await put().catch(() => null);
|
|
183
|
+
}
|
|
184
|
+
else if (!resp) {
|
|
185
|
+
resp = await put().catch(() => null);
|
|
186
|
+
}
|
|
162
187
|
if (!resp?.ok) {
|
|
163
|
-
const
|
|
164
|
-
|
|
188
|
+
const value = resp
|
|
189
|
+
? await readJsonResponseCapped(resp, MAX_DEPLOY_ERROR_RESPONSE_BYTES).catch(() => null)
|
|
190
|
+
: null;
|
|
191
|
+
const body = value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
192
|
+
const message = body?.error && typeof body.error === "object" && typeof body.error.message === "string"
|
|
193
|
+
? body.error.message.slice(0, 2_000)
|
|
194
|
+
: "网络错误";
|
|
195
|
+
throw new Error(`上传 ${f.path} 失败:${message}`);
|
|
165
196
|
}
|
|
197
|
+
void resp.body?.cancel("deploy upload completed").catch(() => { });
|
|
166
198
|
done++;
|
|
167
199
|
process.stdout.write(`\r 上传中 ${done}/${files.length} ${f.path.slice(0, 48).padEnd(48)}`);
|
|
168
200
|
};
|
package/dist/device-auth.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { webcrypto } from "node:crypto";
|
|
2
|
+
import { type IncomingMessage } from "node:http";
|
|
2
3
|
import { type CliConfig } from "./config.js";
|
|
3
4
|
export declare function hasDeviceCredential(cfg: CliConfig): boolean;
|
|
4
5
|
export declare function generateDeviceKeyPair(): Promise<{
|
|
@@ -8,6 +9,8 @@ export declare function generateDeviceKeyPair(): Promise<{
|
|
|
8
9
|
export declare function dpopHeaders(cfg: CliConfig, method: string, url: string): Promise<Record<string, string>>;
|
|
9
10
|
/** Fetch a gateway route with a fresh proof; generic keys remain a read-only compatibility fallback. */
|
|
10
11
|
export declare function authorizedFetch(cfg: CliConfig, input: string | URL, init?: RequestInit): Promise<Response>;
|
|
12
|
+
/** Buffer a loopback request only up to the same JSON limit enforced by Gateway. */
|
|
13
|
+
export declare function readSigningProxyRequestBody(request: IncomingMessage, maxBytes?: number): Promise<Buffer | undefined>;
|
|
11
14
|
interface SigningProxy {
|
|
12
15
|
baseUrl: string;
|
|
13
16
|
localKey: string;
|
package/dist/device-auth.js
CHANGED
|
@@ -2,7 +2,14 @@ import { createHash, randomBytes, randomUUID, webcrypto } from "node:crypto";
|
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { VERSION } from "./config.js";
|
|
4
4
|
const enc = new TextEncoder();
|
|
5
|
+
const SIGNING_PROXY_REQUEST_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
5
6
|
let cachedPrivate;
|
|
7
|
+
class SigningProxyRequestTooLargeError extends Error {
|
|
8
|
+
constructor(maxBytes) {
|
|
9
|
+
super(`local signing proxy request exceeds ${maxBytes} bytes`);
|
|
10
|
+
this.name = "SigningProxyRequestTooLargeError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
6
13
|
function b64url(value) {
|
|
7
14
|
const bytes = typeof value === "string" ? enc.encode(value) : value;
|
|
8
15
|
return Buffer.from(bytes).toString("base64url");
|
|
@@ -86,6 +93,23 @@ function requestHeaders(input) {
|
|
|
86
93
|
}
|
|
87
94
|
return headers;
|
|
88
95
|
}
|
|
96
|
+
/** Buffer a loopback request only up to the same JSON limit enforced by Gateway. */
|
|
97
|
+
export async function readSigningProxyRequestBody(request, maxBytes = SIGNING_PROXY_REQUEST_LIMIT_BYTES) {
|
|
98
|
+
const declaredLength = Number(request.headers["content-length"]);
|
|
99
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
100
|
+
throw new SigningProxyRequestTooLargeError(maxBytes);
|
|
101
|
+
}
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let total = 0;
|
|
104
|
+
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
|
|
105
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
106
|
+
total += bytes.byteLength;
|
|
107
|
+
if (total > maxBytes)
|
|
108
|
+
throw new SigningProxyRequestTooLargeError(maxBytes);
|
|
109
|
+
chunks.push(bytes);
|
|
110
|
+
}
|
|
111
|
+
return chunks.length ? Buffer.concat(chunks, total) : undefined;
|
|
112
|
+
}
|
|
89
113
|
let signingProxy;
|
|
90
114
|
function clientSurface(fallback) {
|
|
91
115
|
const explicit = process.env["U1S1_CLIENT"];
|
|
@@ -123,37 +147,55 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal", clien
|
|
|
123
147
|
res.end(JSON.stringify({ error: { message: "signing proxy only serves /v1/*" } }));
|
|
124
148
|
return;
|
|
125
149
|
}
|
|
150
|
+
const upstreamAbort = new AbortController();
|
|
151
|
+
const abortUpstream = () => {
|
|
152
|
+
if (!res.writableEnded) {
|
|
153
|
+
upstreamAbort.abort(new Error("local signing proxy client disconnected"));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
res.once("close", abortUpstream);
|
|
126
157
|
const target = new URL(`${localUrl.pathname}${localUrl.search}`, upstreamOrigin).toString();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
158
|
+
try {
|
|
159
|
+
const body = await readSigningProxyRequestBody(req);
|
|
160
|
+
const outboundHeaders = requestHeaders(req.headers);
|
|
161
|
+
// Set these at the final local hop so model calls and extension tools share
|
|
162
|
+
// exactly the same attribution, regardless of their upstream SDK defaults.
|
|
163
|
+
outboundHeaders.set("x-u1s1-client", client);
|
|
164
|
+
outboundHeaders.set("x-u1s1-version", VERSION);
|
|
165
|
+
outboundHeaders.set("x-u1s1-platform", `${process.platform}-${process.arch}`);
|
|
166
|
+
if (clientAttestation)
|
|
167
|
+
outboundHeaders.set("x-u1s1-attestation", clientAttestation);
|
|
168
|
+
const upstream = await authorizedFetch(cfg, target, {
|
|
169
|
+
method: req.method ?? "GET",
|
|
170
|
+
headers: outboundHeaders,
|
|
171
|
+
body,
|
|
172
|
+
signal: upstreamAbort.signal,
|
|
173
|
+
});
|
|
174
|
+
const headers = {};
|
|
175
|
+
upstream.headers.forEach((value, name) => {
|
|
176
|
+
if (!["content-length", "transfer-encoding", "connection"].includes(name))
|
|
177
|
+
headers[name] = value;
|
|
178
|
+
});
|
|
179
|
+
res.writeHead(upstream.status, headers);
|
|
180
|
+
if (upstream.body) {
|
|
181
|
+
for await (const chunk of upstream.body)
|
|
182
|
+
res.write(chunk);
|
|
183
|
+
}
|
|
184
|
+
res.end();
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
res.off("close", abortUpstream);
|
|
153
188
|
}
|
|
154
|
-
res.end();
|
|
155
189
|
}
|
|
156
190
|
catch (error) {
|
|
191
|
+
if (error instanceof SigningProxyRequestTooLargeError && !res.destroyed) {
|
|
192
|
+
req.resume();
|
|
193
|
+
res.writeHead(413, { "content-type": "application/json" });
|
|
194
|
+
res.end(JSON.stringify({ error: { message: "local signing proxy request body is too large" } }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (res.destroyed)
|
|
198
|
+
return;
|
|
157
199
|
if (res.headersSent)
|
|
158
200
|
return void res.destroy(error);
|
|
159
201
|
res.writeHead(502, { "content-type": "application/json" });
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { registerLoopCommand } from "./loop.js";
|
|
|
8
8
|
import { ensureSearchTools } from "./search-tools.js";
|
|
9
9
|
import { ensureUsableShell } from "./shell-doctor.js";
|
|
10
10
|
import { applyBrandUi, setAnnouncement, setUpdateNotice } from "./style.js";
|
|
11
|
-
import { AuthError, fetchModels, loadCustomEndpoints } from "./api.js";
|
|
11
|
+
import { AuthError, fetchModels, loadCustomEndpoints, readJsonResponseCapped } from "./api.js";
|
|
12
12
|
import { ensureSigningProxy } from "./device-auth.js";
|
|
13
13
|
const PACKAGE_NAME = "u1s1-cli";
|
|
14
14
|
/** 启动时检测到的可自动安装的新版;TUI 退出后才装(见 installPendingUpdate)。 */
|
|
@@ -32,8 +32,12 @@ async function checkForUpdate() {
|
|
|
32
32
|
signal: AbortSignal.timeout(5_000),
|
|
33
33
|
});
|
|
34
34
|
if (res.ok) {
|
|
35
|
-
const body =
|
|
36
|
-
latest = body.version
|
|
35
|
+
const body = await readJsonResponseCapped(res, 64 * 1024);
|
|
36
|
+
latest = typeof body.version === "string"
|
|
37
|
+
&& body.version.length <= 100
|
|
38
|
+
&& /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(body.version)
|
|
39
|
+
? body.version
|
|
40
|
+
: undefined;
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
43
|
catch {
|
package/dist/login.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { hostname, platform } from "node:os";
|
|
3
|
-
import { fetchMe } from "./api.js";
|
|
3
|
+
import { fetchMe, readJsonResponseCapped } from "./api.js";
|
|
4
4
|
import { printConsoleBanner } from "./brand.js";
|
|
5
5
|
import { loadConfig, saveConfig } from "./config.js";
|
|
6
6
|
import { generateDeviceKeyPair, hasDeviceCredential } from "./device-auth.js";
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
const VERSION = require("../package.json").version;
|
|
9
|
+
const MAX_DEVICE_AUTH_RESPONSE_BYTES = 64 * 1024;
|
|
10
|
+
function boundedInteger(value, fallback, min, max) {
|
|
11
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max
|
|
12
|
+
? value
|
|
13
|
+
: fallback;
|
|
14
|
+
}
|
|
15
|
+
function boundedCredential(value, prefix) {
|
|
16
|
+
return typeof value === "string"
|
|
17
|
+
&& value.startsWith(prefix)
|
|
18
|
+
&& value.length <= 4_096
|
|
19
|
+
&& !/[\u0000-\u001f\u007f]/.test(value)
|
|
20
|
+
? value
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
9
23
|
function tryOpenBrowser(url) {
|
|
10
24
|
import("node:child_process")
|
|
11
25
|
.then(({ spawn }) => {
|
|
@@ -36,16 +50,26 @@ export async function startDeviceLogin(origin) {
|
|
|
36
50
|
}),
|
|
37
51
|
signal: AbortSignal.timeout(15_000),
|
|
38
52
|
});
|
|
39
|
-
if (!resp.ok)
|
|
53
|
+
if (!resp.ok) {
|
|
54
|
+
void resp.body?.cancel("device login start rejected").catch(() => { });
|
|
40
55
|
return null;
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
}
|
|
57
|
+
const data = await readJsonResponseCapped(resp, MAX_DEVICE_AUTH_RESPONSE_BYTES);
|
|
58
|
+
if (typeof data.verify_url !== "string"
|
|
59
|
+
|| data.verify_url.length > 2_048
|
|
60
|
+
|| typeof data.poll_secret !== "string"
|
|
61
|
+
|| !data.poll_secret
|
|
62
|
+
|| data.poll_secret.length > 4_096
|
|
63
|
+
|| /[\u0000-\u001f\u007f]/.test(data.poll_secret))
|
|
64
|
+
return null;
|
|
65
|
+
const verifyUrl = new URL(data.verify_url);
|
|
66
|
+
if (!["http:", "https:"].includes(verifyUrl.protocol) || verifyUrl.username || verifyUrl.password)
|
|
43
67
|
return null;
|
|
44
68
|
return {
|
|
45
|
-
verify_url:
|
|
69
|
+
verify_url: verifyUrl.toString(),
|
|
46
70
|
poll_secret: data.poll_secret,
|
|
47
|
-
interval: data.interval
|
|
48
|
-
expires_in: data.expires_in
|
|
71
|
+
interval: boundedInteger(data.interval, 2, 1, 30),
|
|
72
|
+
expires_in: boundedInteger(data.expires_in, 900, 1, 1_800),
|
|
49
73
|
private_jwk: pair.privateJwk,
|
|
50
74
|
public_jwk: pair.publicJwk,
|
|
51
75
|
};
|
|
@@ -56,9 +80,11 @@ export async function startDeviceLogin(origin) {
|
|
|
56
80
|
}
|
|
57
81
|
/** 轮询等浏览器批准;只接受带设备凭证的新网关响应。outcome 可带出失败原因。 */
|
|
58
82
|
export async function pollDeviceLogin(origin, start, outcome) {
|
|
59
|
-
const
|
|
83
|
+
const interval = boundedInteger(start.interval, 2, 1, 30);
|
|
84
|
+
const expiresIn = boundedInteger(start.expires_in, 900, 1, 1_800);
|
|
85
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
60
86
|
while (Date.now() < deadline) {
|
|
61
|
-
await sleep(
|
|
87
|
+
await sleep(interval * 1000);
|
|
62
88
|
try {
|
|
63
89
|
const resp = await fetch(`${origin}/auth/device/poll`, {
|
|
64
90
|
method: "POST",
|
|
@@ -66,14 +92,22 @@ export async function pollDeviceLogin(origin, start, outcome) {
|
|
|
66
92
|
body: JSON.stringify({ poll_secret: start.poll_secret }),
|
|
67
93
|
signal: AbortSignal.timeout(10_000),
|
|
68
94
|
});
|
|
69
|
-
if (!resp.ok)
|
|
95
|
+
if (!resp.ok) {
|
|
96
|
+
void resp.body?.cancel("device login poll rejected").catch(() => { });
|
|
70
97
|
continue;
|
|
71
|
-
|
|
72
|
-
|
|
98
|
+
}
|
|
99
|
+
const data = await readJsonResponseCapped(resp, MAX_DEVICE_AUTH_RESPONSE_BYTES);
|
|
100
|
+
const apiKey = boundedCredential(data.api_key, "u1s1-");
|
|
101
|
+
const deviceToken = boundedCredential(data.device_token, "u1s1d-");
|
|
102
|
+
if (data.status === "ok" && apiKey && deviceToken) {
|
|
73
103
|
return {
|
|
74
|
-
apiKey
|
|
75
|
-
deviceToken
|
|
76
|
-
deviceId: data.device_id
|
|
104
|
+
apiKey,
|
|
105
|
+
deviceToken,
|
|
106
|
+
deviceId: typeof data.device_id === "number"
|
|
107
|
+
&& Number.isSafeInteger(data.device_id)
|
|
108
|
+
&& data.device_id > 0
|
|
109
|
+
? data.device_id
|
|
110
|
+
: undefined,
|
|
77
111
|
devicePrivateJwk: start.private_jwk,
|
|
78
112
|
devicePublicJwk: start.public_jwk,
|
|
79
113
|
};
|
package/dist/model.d.ts
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
+
import { type ModelsResponse } from "./api.js";
|
|
2
|
+
import { type CliConfig } from "./config.js";
|
|
3
|
+
type ModelFetcher = (cfg: CliConfig) => Promise<ModelsResponse>;
|
|
4
|
+
/** Refresh official models while preserving the built-in list as an explicit offline fallback. */
|
|
5
|
+
export declare function refreshOfficialModels(cfg: CliConfig, fetcher?: ModelFetcher): Promise<string | null>;
|
|
1
6
|
export declare function modelCommand(nameOrAlias?: string): Promise<void>;
|
|
7
|
+
export {};
|
package/dist/model.js
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
|
-
import { loadCustomEndpoints } from "./api.js";
|
|
2
|
-
import { CUSTOM_ENDPOINTS, findModel, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, resolvePreferredModel, } from "./config.js";
|
|
1
|
+
import { fetchModels, loadCustomEndpoints } from "./api.js";
|
|
2
|
+
import { apiModelToDef, CUSTOM_ENDPOINTS, findModel, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, resolvePreferredModel, setModelsFromApi, } from "./config.js";
|
|
3
|
+
/** Refresh official models while preserving the built-in list as an explicit offline fallback. */
|
|
4
|
+
export async function refreshOfficialModels(cfg, fetcher = fetchModels) {
|
|
5
|
+
try {
|
|
6
|
+
const { models } = await fetcher(cfg);
|
|
7
|
+
if (models.length === 0)
|
|
8
|
+
return "线上模型列表为空";
|
|
9
|
+
setModelsFromApi(models.map(apiModelToDef));
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return error instanceof Error ? error.message : String(error);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
3
16
|
export async function modelCommand(nameOrAlias) {
|
|
4
17
|
const cfg = loadConfig();
|
|
5
|
-
//
|
|
6
|
-
await
|
|
18
|
+
// Fetch both live catalogues before resolving the persisted default.
|
|
19
|
+
const [officialModelsError] = await Promise.all([
|
|
20
|
+
refreshOfficialModels(cfg),
|
|
21
|
+
loadCustomEndpoints(cfg),
|
|
22
|
+
]);
|
|
23
|
+
if (officialModelsError) {
|
|
24
|
+
console.error(` 获取线上模型列表失败,使用内置列表:${officialModelsError}`);
|
|
25
|
+
}
|
|
7
26
|
const current = resolvePreferredModel(cfg);
|
|
8
27
|
if (!nameOrAlias) {
|
|
9
28
|
console.log("");
|
|
@@ -26,7 +45,7 @@ export async function modelCommand(nameOrAlias) {
|
|
|
26
45
|
}
|
|
27
46
|
}
|
|
28
47
|
console.log("");
|
|
29
|
-
console.log(" 切换:u1s1 model
|
|
48
|
+
console.log(" 切换:u1s1 model <model-id>(对话里 /model 同样会记住)");
|
|
30
49
|
if (CUSTOM_ENDPOINTS.length) {
|
|
31
50
|
console.log(" 端点模型重名时可加限定:u1s1 model 端点名:模型id");
|
|
32
51
|
}
|
package/dist/search-tools.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
3
3
|
import { arch, platform } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { Readable } from "node:stream";
|
|
5
|
+
import { Readable, Transform } from "node:stream";
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
7
|
import { agentDir } from "./config.js";
|
|
8
8
|
/**
|
|
@@ -24,6 +24,7 @@ const TOOLS = [
|
|
|
24
24
|
];
|
|
25
25
|
const binDir = join(agentDir, "bin");
|
|
26
26
|
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
27
|
+
const MAX_TOOL_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
|
27
28
|
function archStr() {
|
|
28
29
|
const a = arch();
|
|
29
30
|
return a === "arm64" ? "aarch64" : a === "x64" ? "x86_64" : null;
|
|
@@ -110,11 +111,23 @@ async function install(tool, baseUrl, binName) {
|
|
|
110
111
|
});
|
|
111
112
|
if (!res.ok || !res.body)
|
|
112
113
|
throw new Error(`HTTP ${res.status}`);
|
|
114
|
+
const declaredLength = Number(res.headers.get("content-length"));
|
|
115
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_TOOL_ARCHIVE_BYTES) {
|
|
116
|
+
void res.body.cancel("tool archive exceeded limit").catch(() => { });
|
|
117
|
+
throw new Error(`下载文件超过 ${MAX_TOOL_ARCHIVE_BYTES} bytes`);
|
|
118
|
+
}
|
|
113
119
|
const archive = join(binDir, asset);
|
|
114
120
|
// fd/rg 并行安装,解压目录按工具名+pid 隔离,避免互踩
|
|
115
121
|
const extractDir = join(binDir, `preseed_${tool.bin}_${process.pid}`);
|
|
116
122
|
try {
|
|
117
|
-
|
|
123
|
+
let received = 0;
|
|
124
|
+
const limiter = new Transform({
|
|
125
|
+
transform(chunk, _encoding, callback) {
|
|
126
|
+
received += chunk.byteLength;
|
|
127
|
+
callback(received > MAX_TOOL_ARCHIVE_BYTES ? new Error("tool archive exceeded limit") : null, received > MAX_TOOL_ARCHIVE_BYTES ? undefined : chunk);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
await pipeline(Readable.fromWeb(res.body), limiter, createWriteStream(archive));
|
|
118
131
|
mkdirSync(extractDir, { recursive: true });
|
|
119
132
|
if (!extract(archive, extractDir))
|
|
120
133
|
throw new Error(`解压失败 ${asset}`);
|
package/dist/subagent.d.ts
CHANGED
|
@@ -30,6 +30,19 @@ export interface SubagentOutcome {
|
|
|
30
30
|
text: string;
|
|
31
31
|
usage: SubagentUsage;
|
|
32
32
|
}
|
|
33
|
+
type PromptSession = {
|
|
34
|
+
prompt(task: string): Promise<void>;
|
|
35
|
+
abort(): Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Run one prompt while preserving the reason that stopped it. AgentSession.abort()
|
|
39
|
+
* resolves the active prompt normally, so the caller must not infer success merely
|
|
40
|
+
* because prompt() settled after a timeout or parent cancellation.
|
|
41
|
+
*/
|
|
42
|
+
export declare function promptSubagentSession(session: PromptSession, task: string, options: {
|
|
43
|
+
timeoutMs: number;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}): Promise<void>;
|
|
33
46
|
/**
|
|
34
47
|
* Spawn 一个独立上下文的子 agent 执行任务,返回其最终文本输出。
|
|
35
48
|
* 模型解析:"provider/id" 精确匹配;裸 id(如 deepseek-v4-flash)先按原样找,
|
|
@@ -47,3 +60,4 @@ export declare function runPool<T>(input: {
|
|
|
47
60
|
run: (item: T, index: number) => Promise<void>;
|
|
48
61
|
signal?: AbortSignal;
|
|
49
62
|
}): Promise<void>;
|
|
63
|
+
export {};
|
package/dist/subagent.js
CHANGED
|
@@ -6,6 +6,45 @@ export const SUBAGENT_CONCURRENCY = 4;
|
|
|
6
6
|
export const SUBAGENT_TIMEOUT_MS = 15 * 60_000;
|
|
7
7
|
/** ModelRuntime 进程内只建一次(auth/models 解析有启动开销)。 */
|
|
8
8
|
let sharedModelRuntime;
|
|
9
|
+
function cancelledError(signal) {
|
|
10
|
+
return new Error("已取消", { cause: signal.reason });
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Run one prompt while preserving the reason that stopped it. AgentSession.abort()
|
|
14
|
+
* resolves the active prompt normally, so the caller must not infer success merely
|
|
15
|
+
* because prompt() settled after a timeout or parent cancellation.
|
|
16
|
+
*/
|
|
17
|
+
export async function promptSubagentSession(session, task, options) {
|
|
18
|
+
if (options.signal?.aborted)
|
|
19
|
+
throw cancelledError(options.signal);
|
|
20
|
+
let timedOut = false;
|
|
21
|
+
let promptError;
|
|
22
|
+
const abortSession = () => {
|
|
23
|
+
void session.abort().catch(() => { });
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
timedOut = true;
|
|
27
|
+
abortSession();
|
|
28
|
+
}, options.timeoutMs);
|
|
29
|
+
const onParentAbort = abortSession;
|
|
30
|
+
options.signal?.addEventListener("abort", onParentAbort, { once: true });
|
|
31
|
+
try {
|
|
32
|
+
await session.prompt(task);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
promptError = error;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
options.signal?.removeEventListener("abort", onParentAbort);
|
|
40
|
+
}
|
|
41
|
+
if (options.signal?.aborted)
|
|
42
|
+
throw cancelledError(options.signal);
|
|
43
|
+
if (timedOut)
|
|
44
|
+
throw new Error(`子任务超时(${options.timeoutMs}ms)`, { cause: promptError });
|
|
45
|
+
if (promptError)
|
|
46
|
+
throw promptError;
|
|
47
|
+
}
|
|
9
48
|
/** 从会话消息里取最后一条非空 assistant 文本作为子 agent 的最终输出。 */
|
|
10
49
|
function extractFinalText(messages) {
|
|
11
50
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -59,6 +98,8 @@ function extractUsage(messages, model) {
|
|
|
59
98
|
* 失败(模型错误/超时/取消)抛错,由调用方决定容错语义。
|
|
60
99
|
*/
|
|
61
100
|
export async function runSubagent(opts) {
|
|
101
|
+
if (opts.signal?.aborted)
|
|
102
|
+
throw cancelledError(opts.signal);
|
|
62
103
|
sharedModelRuntime ??= await ModelRuntime.create();
|
|
63
104
|
let model;
|
|
64
105
|
if (opts.model) {
|
|
@@ -98,22 +139,17 @@ export async function runSubagent(opts) {
|
|
|
98
139
|
...(model ? { model } : {}),
|
|
99
140
|
...(opts.noTools ? { noTools: "all" } : {}),
|
|
100
141
|
});
|
|
101
|
-
const timeoutMs = opts.timeoutMs ?? SUBAGENT_TIMEOUT_MS;
|
|
102
|
-
const timer = setTimeout(() => void session.abort(), timeoutMs);
|
|
103
|
-
const onParentAbort = () => void session.abort();
|
|
104
|
-
opts.signal?.addEventListener("abort", onParentAbort, { once: true });
|
|
105
142
|
try {
|
|
106
|
-
await session
|
|
143
|
+
await promptSubagentSession(session, opts.task, {
|
|
144
|
+
timeoutMs: opts.timeoutMs ?? SUBAGENT_TIMEOUT_MS,
|
|
145
|
+
signal: opts.signal,
|
|
146
|
+
});
|
|
147
|
+
const messages = session.messages;
|
|
148
|
+
return { ok: true, text: extractFinalText(messages), usage: extractUsage(messages, model) };
|
|
107
149
|
}
|
|
108
150
|
finally {
|
|
109
|
-
clearTimeout(timer);
|
|
110
|
-
opts.signal?.removeEventListener("abort", onParentAbort);
|
|
111
151
|
session.dispose();
|
|
112
152
|
}
|
|
113
|
-
if (opts.signal?.aborted)
|
|
114
|
-
throw new Error("已取消");
|
|
115
|
-
const messages = session.messages;
|
|
116
|
-
return { ok: true, text: extractFinalText(messages), usage: extractUsage(messages, model) };
|
|
117
153
|
}
|
|
118
154
|
/**
|
|
119
155
|
* 固定并发的工作池:limit 个工人依次领任务跑完一个补位一个。
|
package/dist/tools.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import type { CliConfig } from "./config.js";
|
|
5
5
|
export declare function truncate(text: string): string;
|
|
6
|
+
/** 只下载前 maxBytes 就断流,超大页面不用整个拉完再丢。 */
|
|
7
|
+
export declare function readBodyCapped(resp: Response, maxBytes: number): Promise<Uint8Array>;
|
|
6
8
|
/** 联网工具通用渲染:调用行不占位,收起时只显一行摘要,ctrl+o 展开全文,出错显一行 ✗ */
|
|
7
9
|
export declare function compactResultRender(input: {
|
|
8
10
|
result: {
|
|
@@ -43,6 +45,8 @@ export declare function downloadGeneratedImage(url: string, signal: AbortSignal,
|
|
|
43
45
|
attempts?: number;
|
|
44
46
|
retryDelayMs?: number;
|
|
45
47
|
fetchImpl?: typeof fetch;
|
|
48
|
+
maxBytes?: number;
|
|
49
|
+
attemptTimeoutMs?: number;
|
|
46
50
|
}): Promise<Uint8Array>;
|
|
47
51
|
/** 生图工具:走 u1s1 网关代理火山方舟 Seedream,上游 key 不落到用户机器上。 */
|
|
48
52
|
export declare function createImageTool(cfg: Pick<CliConfig, "baseUrl" | "apiKey">): import("@earendil-works/pi-coding-agent").ToolDefinition<Type.TObject<{
|
package/dist/tools.js
CHANGED
|
@@ -8,6 +8,8 @@ import { generateImage, renderPage, searchWeb } from "./api.js";
|
|
|
8
8
|
const FETCH_TIMEOUT_MS = 20_000;
|
|
9
9
|
const MAX_FETCH_BYTES = 2_000_000;
|
|
10
10
|
const MAX_TEXT_CHARS = 30_000;
|
|
11
|
+
const MAX_GENERATED_IMAGE_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
const IMAGE_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
11
13
|
/** 云端渲染是真开浏览器,比直连慢得多,给宽裕些。 */
|
|
12
14
|
const RENDER_TIMEOUT_MS = 45_000;
|
|
13
15
|
/** 200 但榨出的正文比这还短,多半是 JS 渲染的空壳页,值得上浏览器再试。 */
|
|
@@ -39,31 +41,37 @@ export function truncate(text) {
|
|
|
39
41
|
return `${text.slice(0, MAX_TEXT_CHARS)}\n\n…(内容过长已截断,共 ${text.length} 字符)`;
|
|
40
42
|
}
|
|
41
43
|
/** 只下载前 maxBytes 就断流,超大页面不用整个拉完再丢。 */
|
|
42
|
-
async function readBodyCapped(resp, maxBytes) {
|
|
44
|
+
export async function readBodyCapped(resp, maxBytes) {
|
|
43
45
|
if (!resp.body)
|
|
44
46
|
return new Uint8Array(await resp.arrayBuffer()).subarray(0, maxBytes);
|
|
45
47
|
const reader = resp.body.getReader();
|
|
46
48
|
const chunks = [];
|
|
47
49
|
let total = 0;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
try {
|
|
51
|
+
for (;;) {
|
|
52
|
+
const { done, value } = await reader.read();
|
|
53
|
+
if (done)
|
|
54
|
+
break;
|
|
55
|
+
chunks.push(value);
|
|
56
|
+
total += value.byteLength;
|
|
57
|
+
if (total >= maxBytes) {
|
|
58
|
+
void reader.cancel("web fetch body limit reached").catch(() => { });
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
57
61
|
}
|
|
58
62
|
}
|
|
63
|
+
finally {
|
|
64
|
+
reader.releaseLock();
|
|
65
|
+
}
|
|
59
66
|
const out = new Uint8Array(Math.min(total, maxBytes));
|
|
60
67
|
let offset = 0;
|
|
61
68
|
for (const chunk of chunks) {
|
|
62
69
|
const room = out.byteLength - offset;
|
|
63
70
|
if (room <= 0)
|
|
64
71
|
break;
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
const copied = Math.min(room, chunk.byteLength);
|
|
73
|
+
out.set(copied < chunk.byteLength ? chunk.subarray(0, copied) : chunk, offset);
|
|
74
|
+
offset += copied;
|
|
67
75
|
}
|
|
68
76
|
return out;
|
|
69
77
|
}
|
|
@@ -316,6 +324,44 @@ async function abortableDelay(ms, signal) {
|
|
|
316
324
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
317
325
|
});
|
|
318
326
|
}
|
|
327
|
+
class GeneratedImageTooLargeError extends Error {
|
|
328
|
+
}
|
|
329
|
+
async function readGeneratedImageBody(resp, maxBytes) {
|
|
330
|
+
const declaredLength = Number(resp.headers.get("content-length"));
|
|
331
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
332
|
+
if (resp.body)
|
|
333
|
+
void resp.body.cancel("generated image body limit reached").catch(() => { });
|
|
334
|
+
throw new GeneratedImageTooLargeError(`图片文件超过下载上限 ${Math.floor(maxBytes / 1024 / 1024)} MiB`);
|
|
335
|
+
}
|
|
336
|
+
if (!resp.body)
|
|
337
|
+
return new Uint8Array();
|
|
338
|
+
const reader = resp.body.getReader();
|
|
339
|
+
const chunks = [];
|
|
340
|
+
let total = 0;
|
|
341
|
+
try {
|
|
342
|
+
for (;;) {
|
|
343
|
+
const { done, value } = await reader.read();
|
|
344
|
+
if (done)
|
|
345
|
+
break;
|
|
346
|
+
if (total + value.byteLength > maxBytes) {
|
|
347
|
+
void reader.cancel("generated image body limit reached").catch(() => { });
|
|
348
|
+
throw new GeneratedImageTooLargeError(`图片文件超过下载上限 ${Math.floor(maxBytes / 1024 / 1024)} MiB`);
|
|
349
|
+
}
|
|
350
|
+
chunks.push(value);
|
|
351
|
+
total += value.byteLength;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
finally {
|
|
355
|
+
reader.releaseLock();
|
|
356
|
+
}
|
|
357
|
+
const bytes = new Uint8Array(total);
|
|
358
|
+
let offset = 0;
|
|
359
|
+
for (const chunk of chunks) {
|
|
360
|
+
bytes.set(chunk, offset);
|
|
361
|
+
offset += chunk.byteLength;
|
|
362
|
+
}
|
|
363
|
+
return bytes;
|
|
364
|
+
}
|
|
319
365
|
/**
|
|
320
366
|
* 下载方舟返回的同一个临时 URL。fetch() 拿到响应头后,body 仍可能在
|
|
321
367
|
* arrayBuffer() 阶段以 Undici `terminated` 断流,所以两步必须放在同一个
|
|
@@ -325,17 +371,27 @@ export async function downloadGeneratedImage(url, signal, options = {}) {
|
|
|
325
371
|
const attempts = Math.max(1, Math.floor(options.attempts ?? IMAGE_DOWNLOAD_ATTEMPTS));
|
|
326
372
|
const retryDelayMs = Math.max(0, options.retryDelayMs ?? IMAGE_DOWNLOAD_RETRY_DELAY_MS);
|
|
327
373
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
374
|
+
const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? MAX_GENERATED_IMAGE_BYTES));
|
|
375
|
+
const attemptTimeoutMs = Math.max(1, Math.floor(options.attemptTimeoutMs ?? IMAGE_DOWNLOAD_TIMEOUT_MS));
|
|
328
376
|
let lastError;
|
|
329
377
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
330
378
|
try {
|
|
331
|
-
const resp = await fetchImpl(url, {
|
|
332
|
-
|
|
379
|
+
const resp = await fetchImpl(url, {
|
|
380
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(attemptTimeoutMs)]),
|
|
381
|
+
});
|
|
382
|
+
if (!resp.ok) {
|
|
383
|
+
if (resp.body)
|
|
384
|
+
void resp.body.cancel("generated image HTTP error").catch(() => { });
|
|
333
385
|
throw new Error(`HTTP ${resp.status}`);
|
|
334
|
-
|
|
386
|
+
}
|
|
387
|
+
return await readGeneratedImageBody(resp, maxBytes);
|
|
335
388
|
}
|
|
336
389
|
catch (error) {
|
|
337
390
|
if (signal.aborted)
|
|
338
391
|
throw error;
|
|
392
|
+
if (error instanceof GeneratedImageTooLargeError) {
|
|
393
|
+
throw new Error(`图片已经生成,但${error.message}。不要重新调用 generate_image,可在 24 小时内手动下载: ${url}`, { cause: error });
|
|
394
|
+
}
|
|
339
395
|
lastError = error;
|
|
340
396
|
if (attempt < attempts)
|
|
341
397
|
await abortableDelay(retryDelayMs * attempt, signal);
|
|
@@ -425,13 +481,25 @@ async function fetchDirect(url, signal) {
|
|
|
425
481
|
throw new DirectFetchError(`打不开 ${url.href}: ${e.message}`, true);
|
|
426
482
|
}
|
|
427
483
|
if (!resp.ok) {
|
|
484
|
+
void resp.body?.cancel("web fetch rejected HTTP response").catch(() => undefined);
|
|
428
485
|
throw new DirectFetchError(`${url.href} 返回 ${resp.status} ${resp.statusText}`, RENDER_WORTHY_STATUS.has(resp.status));
|
|
429
486
|
}
|
|
430
487
|
const type = resp.headers.get("content-type") ?? "";
|
|
431
488
|
if (!/text\/|json|xml|javascript/i.test(type)) {
|
|
489
|
+
void resp.body?.cancel("web fetch rejected non-text response").catch(() => undefined);
|
|
432
490
|
throw new DirectFetchError(`${url.href} 不是文本内容 (${type || "unknown"}),读不了`, false);
|
|
433
491
|
}
|
|
434
|
-
|
|
492
|
+
let bytes;
|
|
493
|
+
try {
|
|
494
|
+
bytes = await readBodyCapped(resp, MAX_FETCH_BYTES);
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
if (signal?.aborted)
|
|
498
|
+
throw error;
|
|
499
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
500
|
+
throw new DirectFetchError(`${url.href} 正文读取失败: ${detail}`, true);
|
|
501
|
+
}
|
|
502
|
+
const raw = decodeBody(bytes, type);
|
|
435
503
|
const isHtml = /html|xml/i.test(type);
|
|
436
504
|
return { text: isHtml ? htmlToText(raw) : raw.trim(), contentType: type, isHtml };
|
|
437
505
|
}
|
package/dist/update.js
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
2
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { isPortableInstall } from "./config.js";
|
|
8
|
+
import { readJsonResponseCapped, readResponseTextCapped } from "./api.js";
|
|
8
9
|
const require = createRequire(import.meta.url);
|
|
9
10
|
const pkg = require("../package.json");
|
|
10
11
|
export const VERSION = pkg.version;
|
|
11
12
|
export const PACKAGE_NAME = "u1s1-cli";
|
|
13
|
+
const MAX_NPM_METADATA_BYTES = 64 * 1024;
|
|
14
|
+
const MAX_INSTALL_SCRIPT_BYTES = 1024 * 1024;
|
|
15
|
+
function publishedVersion(value) {
|
|
16
|
+
return typeof value === "string"
|
|
17
|
+
&& value.length <= 100
|
|
18
|
+
&& /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value)
|
|
19
|
+
? value
|
|
20
|
+
: undefined;
|
|
21
|
+
}
|
|
12
22
|
/** engines.node 里要求的最低版本(如 ">=22.19.0" → "22.19.0");解析不出返回 undefined。 */
|
|
13
23
|
function requiredNodeVersion() {
|
|
14
24
|
const m = /(\d+\.\d+\.\d+)/.exec(pkg.engines?.node ?? "");
|
|
@@ -36,8 +46,8 @@ export async function getLatestVersion() {
|
|
|
36
46
|
});
|
|
37
47
|
if (!res.ok)
|
|
38
48
|
return undefined;
|
|
39
|
-
const body =
|
|
40
|
-
return body.version;
|
|
49
|
+
const body = await readJsonResponseCapped(res, MAX_NPM_METADATA_BYTES);
|
|
50
|
+
return publishedVersion(body.version);
|
|
41
51
|
}
|
|
42
52
|
catch {
|
|
43
53
|
return undefined;
|
|
@@ -97,7 +107,7 @@ async function portableSelfUpdate(latest) {
|
|
|
97
107
|
});
|
|
98
108
|
if (!res.ok)
|
|
99
109
|
throw new Error(`HTTP ${res.status}`);
|
|
100
|
-
script = await res
|
|
110
|
+
script = await readResponseTextCapped(res, MAX_INSTALL_SCRIPT_BYTES);
|
|
101
111
|
}
|
|
102
112
|
catch {
|
|
103
113
|
console.error("安装脚本下载失败。请手动运行:");
|
|
@@ -107,7 +117,7 @@ async function portableSelfUpdate(latest) {
|
|
|
107
117
|
const tmp = join(mkdtempSync(join(tmpdir(), "u1s1-update-")), "install.sh");
|
|
108
118
|
writeFileSync(tmp, script, { mode: 0o755 });
|
|
109
119
|
try {
|
|
110
|
-
|
|
120
|
+
execFileSync("bash", [tmp], { stdio: "inherit" });
|
|
111
121
|
}
|
|
112
122
|
catch {
|
|
113
123
|
console.error("\n自动升级失败。请手动运行:");
|
|
@@ -171,8 +181,10 @@ export async function update() {
|
|
|
171
181
|
console.log(`正在用 ${pm} 更新 ${PACKAGE_NAME}…`);
|
|
172
182
|
try {
|
|
173
183
|
// npm 压掉 EBADENGINE 等警告墙,对新手只有噪音;出错时 error 仍会显示
|
|
174
|
-
const
|
|
175
|
-
|
|
184
|
+
const args = pm === "npm"
|
|
185
|
+
? ["install", "-g", "--loglevel=error", `${PACKAGE_NAME}@latest`]
|
|
186
|
+
: ["add", "-g", `${PACKAGE_NAME}@latest`];
|
|
187
|
+
execFileSync(pm, args, { stdio: "inherit" });
|
|
176
188
|
console.log(`\n✅ 已更新到 v${latest},重启 u1s1 后生效。`);
|
|
177
189
|
}
|
|
178
190
|
catch {
|
|
@@ -3,6 +3,22 @@ import { type ParentModelRef } from "../subagent.js";
|
|
|
3
3
|
export declare const WORKFLOW_TIMEOUT_MS: number;
|
|
4
4
|
/** 默认整个 run 的 token 预算;0 或负数表示不设限。 */
|
|
5
5
|
export declare const WORKFLOW_DEFAULT_BUDGET_TOKENS = 20000000;
|
|
6
|
+
interface ProgressEntry {
|
|
7
|
+
key: string;
|
|
8
|
+
/** 任务摘要(前 120 字),调试/排查用,key 才是身份。 */
|
|
9
|
+
task: string;
|
|
10
|
+
ok: boolean;
|
|
11
|
+
text: string;
|
|
12
|
+
ms: number;
|
|
13
|
+
}
|
|
14
|
+
/** JSONL 进度存档:一行一个已完成的子任务,断点续跑时按 key 跳过。 */
|
|
15
|
+
export declare class ProgressStore {
|
|
16
|
+
#private;
|
|
17
|
+
readonly path: string;
|
|
18
|
+
constructor(path: string);
|
|
19
|
+
getSuccessful(key: string): ProgressEntry | undefined;
|
|
20
|
+
append(entry: ProgressEntry): void;
|
|
21
|
+
}
|
|
6
22
|
/** 静态白名单校验:语法能编译 + 不碰沙箱之外的任何能力。 */
|
|
7
23
|
export declare function validateScript(code: string): string[];
|
|
8
24
|
export interface WorkflowRunResult {
|
|
@@ -46,3 +62,4 @@ export declare function runWorkflow(opts: WorkflowRunOptions): Promise<WorkflowR
|
|
|
46
62
|
export declare function workflowsDir(): string;
|
|
47
63
|
/** 内联脚本落盘,返回脚本路径(进度存档按同名约定派生)。 */
|
|
48
64
|
export declare function saveWorkflowScript(code: string): string;
|
|
65
|
+
export {};
|
package/dist/workflow/runner.js
CHANGED
|
@@ -15,7 +15,7 @@ const MAX_LOG_LINES = 60;
|
|
|
15
15
|
/** 默认整个 run 的 token 预算;0 或负数表示不设限。 */
|
|
16
16
|
export const WORKFLOW_DEFAULT_BUDGET_TOKENS = 20_000_000;
|
|
17
17
|
/** JSONL 进度存档:一行一个已完成的子任务,断点续跑时按 key 跳过。 */
|
|
18
|
-
class ProgressStore {
|
|
18
|
+
export class ProgressStore {
|
|
19
19
|
path;
|
|
20
20
|
#cache = new Map();
|
|
21
21
|
constructor(path) {
|
|
@@ -36,8 +36,9 @@ class ProgressStore {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
getSuccessful(key) {
|
|
40
|
+
const entry = this.#cache.get(key);
|
|
41
|
+
return entry?.ok === true && typeof entry.text === "string" ? entry : undefined;
|
|
41
42
|
}
|
|
42
43
|
append(entry) {
|
|
43
44
|
this.#cache.set(entry.key, entry);
|
|
@@ -154,14 +155,12 @@ export async function runWorkflow(opts) {
|
|
|
154
155
|
}
|
|
155
156
|
const key = taskKey(task, model);
|
|
156
157
|
if (opts.resume) {
|
|
157
|
-
const prev = store.
|
|
158
|
+
const prev = store.getSuccessful(key);
|
|
158
159
|
if (prev) {
|
|
159
160
|
stats.cached++;
|
|
160
161
|
progress.cached = stats.cached;
|
|
161
162
|
reportProgress();
|
|
162
|
-
|
|
163
|
-
return prev.text;
|
|
164
|
-
throw new Error(prev.text);
|
|
163
|
+
return prev.text;
|
|
165
164
|
}
|
|
166
165
|
}
|
|
167
166
|
stats.spawns++;
|
|
@@ -127,14 +127,14 @@ return { synthesis };
|
|
|
127
127
|
},
|
|
128
128
|
refactor: {
|
|
129
129
|
name: "refactor",
|
|
130
|
-
description: "批量重构/迁移:先由一个 agent 读代表文件制定统一规则,然后每个文件一个子 agent
|
|
130
|
+
description: "批量重构/迁移:先由一个 agent 读代表文件制定统一规则,然后每个文件一个子 agent 在当前工作区套用规则",
|
|
131
131
|
inputHint: '{ "instruction": "把所有 var 改为 const", "files": ["src/a.ts", ...] } — files 省略时用 git diff 改动文件',
|
|
132
132
|
build(input) {
|
|
133
133
|
const instruction = str(input.instruction);
|
|
134
134
|
if (!instruction)
|
|
135
135
|
throw new Error('refactor 模板需要 instruction,例如 { "instruction": "把 var 全部改为 const" }');
|
|
136
136
|
// 与 review 模板一致:构建期取 git 改动文件,不花一个 subagent 在沙箱里跑 git
|
|
137
|
-
let files = strArray(input.files);
|
|
137
|
+
let files = [...new Set(strArray(input.files))];
|
|
138
138
|
if (files.length === 0)
|
|
139
139
|
files = gitDiffFiles();
|
|
140
140
|
if (files.length === 0)
|
|
@@ -150,8 +150,8 @@ const rule = await subagent({
|
|
|
150
150
|
});
|
|
151
151
|
const results = await parallel(targetFiles.map(f => () =>
|
|
152
152
|
subagent({
|
|
153
|
-
task: "按以下重构规则处理文件 " + f + "
|
|
154
|
-
|
|
153
|
+
task: "按以下重构规则处理文件 " + f + "。只修改这个目标文件,不要改动其他文件,避免与并行任务冲突。" +
|
|
154
|
+
"改完检查该文件语法正确(必要时运行 tsc 或构建验证)。\\n\\n重构规则:\\n" + rule
|
|
155
155
|
})
|
|
156
156
|
));
|
|
157
157
|
const done = targetFiles.filter((_, i) => results[i] !== null);
|