@trim21/personal-pi-extensions 0.1.689 → 0.1.691
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 +3 -3
- package/package.json +2 -2
- package/src/bwrap/dcg-scan.ts +2 -8
- package/src/bwrap/html.ts +13 -0
- package/src/bwrap/network-stack.ts +2 -1
- package/src/bwrap/runtime.ts +1 -8
- package/src/claude-code/grep.ts +2 -1
- package/src/claude-code/shell.ts +13 -42
- package/src/codemode/declarations.ts +3 -8
- package/src/codemode/worker.js +11 -7
- package/src/codemode/worker.ts +10 -6
- package/src/gh/base.ts +91 -23
- package/src/gh/index.ts +8 -5
- package/src/gh/tools/download-release-assets.ts +6 -3
- package/src/gh/tools/get-workflow-jobs.ts +4 -7
- package/src/gh/tools/list-issues.ts +6 -39
- package/src/gh/tools/list-prs.ts +6 -39
- package/src/gh/tools/list-releases.ts +6 -9
- package/src/gh/tools/list-workflow-runs.ts +6 -9
- package/src/gh/tools/read-ci-logs.ts +8 -19
- package/src/gh/tools/read-issue-comments.ts +4 -12
- package/src/gh/tools/read-issue.ts +4 -12
- package/src/gh/tools/read-pr-comments.ts +6 -14
- package/src/gh/tools/read-pr-diff.ts +8 -8
- package/src/gh/tools/read-pr-status.ts +6 -4
- package/src/gh/tools/read-pr.ts +4 -12
- package/src/gh/tools/read-release.ts +5 -8
- package/src/gh/tools/read-repo.ts +5 -9
- package/src/gh/tools/wait-commit-checks.ts +2 -4
- package/src/gh/tools/wait-pr-checks.ts +2 -4
- package/src/gh/tools/watch-run.ts +7 -4
- package/src/lib/abort.ts +10 -0
- package/src/lib/bash-tool.ts +48 -0
- package/src/lib/github-reads.ts +3 -23
- package/src/lib/github.ts +81 -41
- package/src/lib/lsp/client.ts +1 -1
- package/src/lib/lsp/server-config.ts +1 -0
- package/src/lib/model-call.ts +3 -7
- package/src/lib/tools-config.ts +2 -4
- package/src/opencode/bash.ts +11 -42
- package/src/opencode/files.ts +1 -0
- package/src/opencode/glob.ts +1 -6
- package/src/opencode/grep.ts +1 -6
- package/src/opencode/ripgrep.ts +5 -0
- package/src/talk/policy.ts +4 -4
- package/src/web/fetch.ts +2 -6
- package/src/web/search.ts +2 -6
- package/src/orphan-worker-cleaner.ts +0 -38
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-code 与 opencode 两套 Bash 工具共用的部分:超时上下限、结构化结果 schema、
|
|
3
|
+
* 以及截断后从落盘文件读回完整输出。
|
|
4
|
+
*
|
|
5
|
+
* 只放两边逐字相同的部分。两套工具的截断提示文案、失败判定与 details 是刻意的差异,
|
|
6
|
+
* 留在各自的工具里,不在这里统一。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
|
|
11
|
+
import type { TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { Type } from "typebox";
|
|
13
|
+
|
|
14
|
+
export const BASH_DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
|
+
export const BASH_MAX_TIMEOUT_MS = 7_200_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `Bash` 的结构化结果:只有退出码与完整输出——命令非零退出不是失败,所以载荷里没有成败
|
|
19
|
+
* 标志,脚本直接读 `exitCode` 分支;超时/中止时 `exitCode` 为 `null`(原因在模型侧文本里)。
|
|
20
|
+
*/
|
|
21
|
+
export const bashStructuredSchema = Type.Object({
|
|
22
|
+
exitCode: Type.Union([Type.Number(), Type.Null()], {
|
|
23
|
+
description: "Exit code of the command; null when it was killed (timeout or abort)",
|
|
24
|
+
}),
|
|
25
|
+
output: Type.String({
|
|
26
|
+
description:
|
|
27
|
+
"Complete output of the command (stdout and stderr merged). Never truncated, never mixed with tool-added notices.",
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 载荷里的输出:文本被截断时读回落盘的完整输出(读不到就退回那份截断文本),
|
|
33
|
+
* 因此脚本拿到的永远是命令真正输出的内容。
|
|
34
|
+
*/
|
|
35
|
+
export async function spilledOutput(
|
|
36
|
+
output: string,
|
|
37
|
+
truncation: TruncationResult,
|
|
38
|
+
spillPath: string | undefined,
|
|
39
|
+
): Promise<string> {
|
|
40
|
+
if (spillPath === undefined || !truncation.truncated) {
|
|
41
|
+
return output;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return await readFile(spillPath, "utf8");
|
|
45
|
+
} catch {
|
|
46
|
+
return output;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/lib/github-reads.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
15
15
|
|
|
16
16
|
import type { Octokit } from "octokit";
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { type GithubApi } from "./github.js";
|
|
19
19
|
|
|
20
20
|
/** `actions.listWorkflowRuns*` 的 `status` 取值(octokit 是字面量联合,工具参数是 string)。 */
|
|
21
21
|
type WorkflowRunParams = NonNullable<
|
|
@@ -146,23 +146,6 @@ function limitOf(limit: number | undefined, fallback: number): number {
|
|
|
146
146
|
return Math.min(Math.max(limit ?? fallback, 1), 100);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
/** 响应体不是 2xx 时抛错(状态、URL 与 GitHub 的 message 都在里面)。 */
|
|
150
|
-
async function ensureOk(response: Response): Promise<Response> {
|
|
151
|
-
if (response.ok) {
|
|
152
|
-
return response;
|
|
153
|
-
}
|
|
154
|
-
let detail: string | undefined;
|
|
155
|
-
try {
|
|
156
|
-
const body = await response.text();
|
|
157
|
-
detail = (JSON.parse(body) as { message?: string }).message ?? body.slice(0, 200);
|
|
158
|
-
} catch {
|
|
159
|
-
detail = undefined;
|
|
160
|
-
}
|
|
161
|
-
throw new Error(
|
|
162
|
-
`GitHub API error (HTTP ${response.status}) at ${response.url}${detail === undefined || detail === "" ? "" : `: ${detail}`}`,
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
149
|
function octokitRequest(signal: AbortSignal | undefined): { signal?: AbortSignal } {
|
|
167
150
|
return signal === undefined ? {} : { signal };
|
|
168
151
|
}
|
|
@@ -172,9 +155,7 @@ function asJson(value: unknown): unknown {
|
|
|
172
155
|
return value;
|
|
173
156
|
}
|
|
174
157
|
|
|
175
|
-
export function createGithubReads(
|
|
176
|
-
const api = createGithubApi(options);
|
|
177
|
-
|
|
158
|
+
export function createGithubReads(api: GithubApi): GithubReads {
|
|
178
159
|
return {
|
|
179
160
|
async issue(owner, repo, number, signal) {
|
|
180
161
|
const { data } = await api.call((octokit) =>
|
|
@@ -373,11 +354,11 @@ export function createGithubReads(options: GithubClientOptions = {}): GithubRead
|
|
|
373
354
|
},
|
|
374
355
|
|
|
375
356
|
async downloadAssetTo(owner, repo, assetId, destPath, signal) {
|
|
357
|
+
// rawFetch 非 2xx 直接抛 GithubApiError;这里只需流式落盘
|
|
376
358
|
const response = await api.rawFetch(
|
|
377
359
|
`https://api.github.com/repos/${owner}/${repo}/releases/assets/${assetId}`,
|
|
378
360
|
{ headers: { accept: "application/octet-stream" }, ...(signal && { signal }) },
|
|
379
361
|
);
|
|
380
|
-
await ensureOk(response);
|
|
381
362
|
await streamToFile(response, destPath);
|
|
382
363
|
},
|
|
383
364
|
|
|
@@ -388,7 +369,6 @@ export function createGithubReads(options: GithubClientOptions = {}): GithubRead
|
|
|
388
369
|
`https://api.github.com/repos/${owner}/${repo}/${kind}${suffix}`,
|
|
389
370
|
{ ...(signal && { signal }) },
|
|
390
371
|
);
|
|
391
|
-
await ensureOk(response);
|
|
392
372
|
await streamToFile(response, destPath);
|
|
393
373
|
},
|
|
394
374
|
};
|
package/src/lib/github.ts
CHANGED
|
@@ -270,21 +270,41 @@ export function normalizeRestList(items: unknown, repo: string): SearchHit[] {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
/**
|
|
273
|
-
*
|
|
274
|
-
*
|
|
273
|
+
* GitHub 请求失败:octokit 调用与非 JSON 的原始下载共用这一个类型,调用方据此区分
|
|
274
|
+
* 「不存在」(`isNotFound`)与真失败,不用再各自去剥错误对象上的 status。
|
|
275
|
+
*
|
|
276
|
+
* `input` 是触发该请求的工具参数;带上它,模型才能在报错里看到自己给的输入。
|
|
275
277
|
*/
|
|
276
|
-
class
|
|
277
|
-
|
|
278
|
+
export class GithubApiError extends Error {
|
|
279
|
+
/** HTTP 状态;连响应都没拿到(网络错误)时为 undefined。 */
|
|
278
280
|
readonly status: number | undefined;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
281
|
+
readonly input: unknown;
|
|
282
|
+
|
|
283
|
+
constructor(
|
|
284
|
+
message: string,
|
|
285
|
+
options: { status?: number; input?: unknown; cause?: unknown } = {},
|
|
286
|
+
) {
|
|
287
|
+
const inputText =
|
|
288
|
+
options.input === undefined ? "" : ` (input: ${JSON.stringify(options.input)})`;
|
|
289
|
+
super(
|
|
290
|
+
`${message}${inputText}`,
|
|
291
|
+
options.cause === undefined ? undefined : { cause: options.cause },
|
|
292
|
+
);
|
|
293
|
+
this.name = "GithubApiError";
|
|
294
|
+
this.status = options.status;
|
|
295
|
+
this.input = options.input;
|
|
285
296
|
}
|
|
286
297
|
}
|
|
287
298
|
|
|
299
|
+
/** 404:资源不存在或 token 没有访问权限——与网络/限流错误分开处理。 */
|
|
300
|
+
export function isNotFound(error: unknown): boolean {
|
|
301
|
+
return error instanceof GithubApiError && error.status === 404;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const errorStatus = (error: unknown): number | undefined => (error as { status?: number }).status;
|
|
305
|
+
const errorMessage = (error: unknown): string =>
|
|
306
|
+
(error as { message?: string }).message ?? String(error);
|
|
307
|
+
|
|
288
308
|
function describeHttpError(status: number | undefined): string {
|
|
289
309
|
if (status === 401) {
|
|
290
310
|
return 'GitHub auth failed (401): token invalid or expired — run "gh auth login"';
|
|
@@ -298,12 +318,35 @@ function describeHttpError(status: number | undefined): string {
|
|
|
298
318
|
return `GitHub API error${status === undefined ? "" : ` (HTTP ${status})`}`;
|
|
299
319
|
}
|
|
300
320
|
|
|
321
|
+
/** 非 2xx 的原始响应(二进制下载)转成错误:状态、URL 与 GitHub 的 message 一起给出。 */
|
|
322
|
+
async function ensureOk(response: Response): Promise<Response> {
|
|
323
|
+
if (response.ok) {
|
|
324
|
+
return response;
|
|
325
|
+
}
|
|
326
|
+
let detail: string | undefined;
|
|
327
|
+
try {
|
|
328
|
+
const body = await response.text();
|
|
329
|
+
detail = (JSON.parse(body) as { message?: string }).message ?? body.slice(0, 200);
|
|
330
|
+
} catch {
|
|
331
|
+
detail = undefined;
|
|
332
|
+
}
|
|
333
|
+
throw new GithubApiError(
|
|
334
|
+
`GitHub API error (HTTP ${response.status}) at ${response.url}${detail === undefined || detail === "" ? "" : `: ${detail}`}`,
|
|
335
|
+
{ status: response.status },
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
301
339
|
export interface GithubApi {
|
|
302
|
-
/**
|
|
303
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Run an octokit request; retries once with a fresh token on 401. Failures
|
|
342
|
+
* are rethrown as `GithubApiError`; `input` (the toolcall params behind the
|
|
343
|
+
* request) is attached to that error when given.
|
|
344
|
+
*/
|
|
345
|
+
call<T>(fn: (octokit: Octokit) => Promise<T>, input?: unknown): Promise<T>;
|
|
304
346
|
/**
|
|
305
347
|
* 用同一个 token 直接 fetch(二进制资产、源码归档这类要走原始响应体、不能经 octokit
|
|
306
|
-
* 的 JSON 解析的请求)。重定向自动跟随,401
|
|
348
|
+
* 的 JSON 解析的请求)。重定向自动跟随,401 同样丢缓存重试一次;非 2xx 抛
|
|
349
|
+
* `GithubApiError`(状态、URL 与 GitHub 的 message 都在里面)。
|
|
307
350
|
*/
|
|
308
351
|
rawFetch(url: string, init?: RequestInit): Promise<Response>;
|
|
309
352
|
}
|
|
@@ -361,18 +404,26 @@ export function createGithubApi(options: GithubClientOptions = {}): GithubApi {
|
|
|
361
404
|
}
|
|
362
405
|
|
|
363
406
|
return {
|
|
364
|
-
async call(fn) {
|
|
407
|
+
async call(fn, input) {
|
|
365
408
|
for (let attempt = 0; ; attempt += 1) {
|
|
366
409
|
try {
|
|
367
410
|
return await fn(await getClient());
|
|
368
411
|
} catch (error) {
|
|
369
|
-
const status = (error
|
|
412
|
+
const status = errorStatus(error);
|
|
370
413
|
if (status === 401 && attempt === 0 && client) {
|
|
371
414
|
token = undefined;
|
|
372
415
|
client = undefined;
|
|
373
416
|
continue;
|
|
374
417
|
}
|
|
375
|
-
|
|
418
|
+
// 统一在这里成 GithubApiError:调用方(工具、诊断)拿到的是同一种错误形态,
|
|
419
|
+
// 不必各自去剥 status / message。
|
|
420
|
+
throw error instanceof GithubApiError
|
|
421
|
+
? error
|
|
422
|
+
: new GithubApiError(`${describeHttpError(status)}: ${errorMessage(error)}`, {
|
|
423
|
+
status,
|
|
424
|
+
input,
|
|
425
|
+
cause: error,
|
|
426
|
+
});
|
|
376
427
|
}
|
|
377
428
|
}
|
|
378
429
|
},
|
|
@@ -381,7 +432,7 @@ export function createGithubApi(options: GithubClientOptions = {}): GithubApi {
|
|
|
381
432
|
for (let attempt = 0; ; attempt += 1) {
|
|
382
433
|
const response = await rawFetchOnce(url, init);
|
|
383
434
|
if (response.status !== 401 || attempt > 0) {
|
|
384
|
-
return response;
|
|
435
|
+
return ensureOk(response);
|
|
385
436
|
}
|
|
386
437
|
await response.body?.cancel();
|
|
387
438
|
}
|
|
@@ -394,31 +445,22 @@ export interface GithubSearch {
|
|
|
394
445
|
}
|
|
395
446
|
|
|
396
447
|
/**
|
|
397
|
-
* Create a search client
|
|
448
|
+
* Create a search client on top of a shared API accessor (见 `createGithubApi`):
|
|
449
|
+
* 一个会话只应有一个 accessor,搜索 / checks / reads 共用同一份 octokit 与 token 缓存。
|
|
398
450
|
*/
|
|
399
|
-
export function createGithubSearch(
|
|
400
|
-
const api = createGithubApi(options);
|
|
401
|
-
|
|
451
|
+
export function createGithubSearch(api: GithubApi): GithubSearch {
|
|
402
452
|
return {
|
|
403
453
|
async search(kind, params) {
|
|
404
454
|
const limit = Math.min(Math.max(params.limit ?? 30, 1), 100);
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const { data } = await client.rest.search.issuesAndPullRequests({
|
|
412
|
-
q,
|
|
413
|
-
per_page: limit,
|
|
414
|
-
});
|
|
415
|
-
return data.items.map((item) => normalize(parseWithSchema(searchItemSchema, item)));
|
|
455
|
+
// `@me` 是 gh CLI 的简写,REST 搜索不认识:这里不展开,它按字面量进入查询串。
|
|
456
|
+
const q = buildSearchQuery(kind, { ...params, limit });
|
|
457
|
+
return await api.call(async (client) => {
|
|
458
|
+
const { data } = await client.rest.search.issuesAndPullRequests({
|
|
459
|
+
q,
|
|
460
|
+
per_page: limit,
|
|
416
461
|
});
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const message = (error as { message?: string }).message ?? String(error);
|
|
420
|
-
throw new GithubSearchError(`${describeHttpError(status)}: ${message}`, params, status);
|
|
421
|
-
}
|
|
462
|
+
return data.items.map((item) => normalize(parseWithSchema(searchItemSchema, item)));
|
|
463
|
+
}, params);
|
|
422
464
|
},
|
|
423
465
|
};
|
|
424
466
|
}
|
|
@@ -522,7 +564,7 @@ export interface GithubChecksClient {
|
|
|
522
564
|
}
|
|
523
565
|
|
|
524
566
|
/**
|
|
525
|
-
* Create a client for PR CI checks
|
|
567
|
+
* Create a client for PR CI checks on top of a shared API accessor.
|
|
526
568
|
* Covers both check sources GitHub exposes for a commit — classic commit
|
|
527
569
|
* statuses (Azure DevOps, Jenkins, ...) and check runs (GitHub Actions,
|
|
528
570
|
* GitHub Apps) — so external CI is visible to the caller.
|
|
@@ -553,9 +595,7 @@ function toRunJob(job: ApiJob): RunJob {
|
|
|
553
595
|
};
|
|
554
596
|
}
|
|
555
597
|
|
|
556
|
-
export function createGithubChecks(
|
|
557
|
-
const api = createGithubApi(options);
|
|
558
|
-
|
|
598
|
+
export function createGithubChecks(api: GithubApi): GithubChecksClient {
|
|
559
599
|
return {
|
|
560
600
|
async statuses(owner, repo, ref, signal) {
|
|
561
601
|
const { data } = await api.call((octokit) =>
|
package/src/lib/lsp/client.ts
CHANGED
|
@@ -1466,7 +1466,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1466
1466
|
const toPaths = (locations: { uri: string }[] | null): Set<string> =>
|
|
1467
1467
|
new Set(
|
|
1468
1468
|
(locations ?? []).flatMap((location) =>
|
|
1469
|
-
location.uri.startsWith("file:") ?
|
|
1469
|
+
location.uri.startsWith("file:") ? normalize(fileURLToPath(location.uri)) : [],
|
|
1470
1470
|
),
|
|
1471
1471
|
);
|
|
1472
1472
|
|
|
@@ -112,6 +112,7 @@ function resolveTemplate(template: string, root: string, cwd: string): string {
|
|
|
112
112
|
const envVarPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
113
113
|
|
|
114
114
|
function interpolateEnvVars(value: string, env: NodeJS.ProcessEnv): string {
|
|
115
|
+
// eslint-disable-next-line unicorn/prefer-default-parameters -- replaceAll 回调参数被上下文的 ...args: any[] 定成 any,默认参数无法保留 string 注解
|
|
115
116
|
return value.replaceAll(envVarPattern, (_match, name: string, fallback?: string) => {
|
|
116
117
|
const resolved = env[name];
|
|
117
118
|
return resolved !== undefined && resolved !== "" ? resolved : (fallback ?? "");
|
package/src/lib/model-call.ts
CHANGED
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
type UserMessage,
|
|
26
26
|
} from "@earendil-works/pi-ai";
|
|
27
27
|
|
|
28
|
+
import { withTimeoutSignal } from "./abort.js";
|
|
29
|
+
|
|
28
30
|
/**
|
|
29
31
|
* 模型注册表操作:扩展传 `ctx.modelRegistry`,测试传 mock。
|
|
30
32
|
* 结构化类型(duck typing),只声明用到的两个方法。
|
|
@@ -58,12 +60,6 @@ export interface CompleteTextResult {
|
|
|
58
60
|
usage: Usage;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
/** 合并调用方 signal 与本地超时;调用方未传时仍然有超时兜底 */
|
|
62
|
-
function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
63
|
-
const timeout = AbortSignal.timeout(ms);
|
|
64
|
-
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
63
|
/**
|
|
68
64
|
* 用给定模型生成正文。`registry.complete` 抛出的错误原样向上抛(含
|
|
69
65
|
* `AbortError`),取消与失败的区分由调用方决定。
|
|
@@ -79,7 +75,7 @@ export async function completeText(options: CompleteTextOptions): Promise<Comple
|
|
|
79
75
|
},
|
|
80
76
|
{
|
|
81
77
|
maxTokens: options.maxTokens ?? options.model.maxTokens,
|
|
82
|
-
signal:
|
|
78
|
+
signal: withTimeoutSignal(options.signal, options.timeoutMs),
|
|
83
79
|
},
|
|
84
80
|
);
|
|
85
81
|
const text = contentText(result.content).trim();
|
package/src/lib/tools-config.ts
CHANGED
|
@@ -25,6 +25,8 @@ import { minimatch } from "minimatch";
|
|
|
25
25
|
import { Type } from "typebox";
|
|
26
26
|
import { Value } from "typebox/value";
|
|
27
27
|
|
|
28
|
+
import { isRecord } from "./narrow.js";
|
|
29
|
+
|
|
28
30
|
// ── schema ───────────────────────────────────────────────────────────────────
|
|
29
31
|
|
|
30
32
|
const fileIoSchema = Type.Union([Type.Literal("claude-code"), Type.Literal("opencode")]);
|
|
@@ -88,10 +90,6 @@ export const DEFAULT_FILE_IO: FileIoToolset = "claude-code";
|
|
|
88
90
|
|
|
89
91
|
// ── 读取与解析 ───────────────────────────────────────────────────────────────
|
|
90
92
|
|
|
91
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
92
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
93
|
function parseFileIo(value: unknown, field: string, warnings: string[]): FileIoToolset | undefined {
|
|
96
94
|
if (value === undefined) {
|
|
97
95
|
return undefined;
|
package/src/opencode/bash.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
2
|
import { fileURLToPath } from "node:url";
|
|
4
3
|
|
|
5
4
|
import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
@@ -12,53 +11,23 @@ import {
|
|
|
12
11
|
formatElapsedSeconds,
|
|
13
12
|
sandboxHintBlock,
|
|
14
13
|
} from "../bwrap/runtime.js";
|
|
14
|
+
import {
|
|
15
|
+
BASH_DEFAULT_TIMEOUT_MS,
|
|
16
|
+
BASH_MAX_TIMEOUT_MS,
|
|
17
|
+
bashStructuredSchema,
|
|
18
|
+
spilledOutput,
|
|
19
|
+
} from "../lib/bash-tool.js";
|
|
15
20
|
import { resolveWorkdir } from "../lib/path.js";
|
|
16
21
|
import { createRequestPolicy } from "../lib/request-policy.js";
|
|
17
22
|
import { defineStructuredTool, type ToolBus } from "../lib/tool-bus.js";
|
|
18
23
|
import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
|
|
19
24
|
|
|
20
|
-
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
21
|
-
const MAX_TIMEOUT_MS = 7_200_000;
|
|
22
|
-
|
|
23
25
|
/** 对齐上游 opencode 的截断提示文案(tools/BashTool/bash.ts)。 */
|
|
24
26
|
const CAPTURE_TRUNCATED_NOTICE = "[output capture truncated at the in-memory safety limit]";
|
|
25
27
|
|
|
26
28
|
/** Bash tool guidance, kept in markdown so it reads like documentation. */
|
|
27
29
|
const BASH_PROMPT = readFileSync(fileURLToPath(new URL("bash.md", import.meta.url)), "utf8").trim();
|
|
28
30
|
|
|
29
|
-
/**
|
|
30
|
-
* `bash` 的结构化结果:只有退出码与完整输出——命令非零退出不是失败,所以载荷里没有成败
|
|
31
|
-
* 标志,脚本直接读 `exitCode` 分支;超时/中止时 `exitCode` 为 `null`(原因在模型侧文本里)。
|
|
32
|
-
*/
|
|
33
|
-
const bashStructuredSchema = Type.Object({
|
|
34
|
-
exitCode: Type.Union([Type.Number(), Type.Null()], {
|
|
35
|
-
description: "Exit code of the command; null when it was killed (timeout or abort)",
|
|
36
|
-
}),
|
|
37
|
-
output: Type.String({
|
|
38
|
-
description:
|
|
39
|
-
"Complete output of the command (stdout and stderr merged). Never truncated, never mixed with tool-added notices.",
|
|
40
|
-
}),
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* 载荷里的输出:文本被截断时读回落盘的完整输出(读不到就退回那份截断文本),
|
|
45
|
-
* 因此脚本拿到的永远是命令真正输出的内容。
|
|
46
|
-
*/
|
|
47
|
-
async function fullOutput(
|
|
48
|
-
output: string,
|
|
49
|
-
truncation: TruncationResult,
|
|
50
|
-
spillPath: string | undefined,
|
|
51
|
-
): Promise<string> {
|
|
52
|
-
if (spillPath === undefined || !truncation.truncated) {
|
|
53
|
-
return output;
|
|
54
|
-
}
|
|
55
|
-
try {
|
|
56
|
-
return await readFile(spillPath, "utf8");
|
|
57
|
-
} catch {
|
|
58
|
-
return output;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
31
|
/** opencode 风格截断提示(成功、超时、中断路径共用)。 */
|
|
63
32
|
function appendTruncationNotice(
|
|
64
33
|
text: string,
|
|
@@ -127,9 +96,9 @@ export function registerBashTool(bus: ToolBus, pi: ExtensionAPI, runtime: BwrapR
|
|
|
127
96
|
{ additionalProperties: false },
|
|
128
97
|
),
|
|
129
98
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
130
|
-
const timeout = params.timeout ??
|
|
131
|
-
if (!Number.isFinite(timeout) || timeout <= 0 || timeout >
|
|
132
|
-
throw new Error(`timeout must be between 1 and ${
|
|
99
|
+
const timeout = params.timeout ?? BASH_DEFAULT_TIMEOUT_MS;
|
|
100
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || timeout > BASH_MAX_TIMEOUT_MS) {
|
|
101
|
+
throw new Error(`timeout must be between 1 and ${BASH_MAX_TIMEOUT_MS} milliseconds`);
|
|
133
102
|
}
|
|
134
103
|
|
|
135
104
|
const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
|
|
@@ -175,7 +144,7 @@ export function registerBashTool(bus: ToolBus, pi: ExtensionAPI, runtime: BwrapR
|
|
|
175
144
|
ok: true as const,
|
|
176
145
|
value: {
|
|
177
146
|
exitCode: null,
|
|
178
|
-
output: await
|
|
147
|
+
output: await spilledOutput(
|
|
179
148
|
error.partial.output,
|
|
180
149
|
error.partial.truncation,
|
|
181
150
|
error.partial.fullOutputPath,
|
|
@@ -208,7 +177,7 @@ export function registerBashTool(bus: ToolBus, pi: ExtensionAPI, runtime: BwrapR
|
|
|
208
177
|
ok: true as const,
|
|
209
178
|
value: {
|
|
210
179
|
exitCode: result.exitCode,
|
|
211
|
-
output: await
|
|
180
|
+
output: await spilledOutput(result.output, result.truncation, result.fullOutputPath),
|
|
212
181
|
},
|
|
213
182
|
},
|
|
214
183
|
};
|
package/src/opencode/files.ts
CHANGED
|
@@ -446,6 +446,7 @@ function registerReadTool(bus: ToolBus, getService: () => LspService, state: Rea
|
|
|
446
446
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
447
447
|
signal?.throwIfAborted();
|
|
448
448
|
|
|
449
|
+
// eslint-disable-next-line unicorn/prefer-default-parameters -- params.offset || 1:0 视为 1,默认参数只覆盖 undefined
|
|
449
450
|
const { filePath: rawPath, offset, limit } = params;
|
|
450
451
|
|
|
451
452
|
const absolutePath = isAbsolute(rawPath) ? rawPath : resolvePath(ctx.cwd, rawPath);
|
package/src/opencode/glob.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { Type } from "typebox";
|
|
|
21
21
|
import type { ToolBus } from "../lib/tool-bus.js";
|
|
22
22
|
import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
|
|
23
23
|
import { didYouMean } from "./files.js";
|
|
24
|
-
import { RIPGREP_RESULT_LIMIT, runRipgrep } from "./ripgrep.js";
|
|
24
|
+
import { normalizeRipgrepPath, RIPGREP_RESULT_LIMIT, runRipgrep } from "./ripgrep.js";
|
|
25
25
|
|
|
26
26
|
/** Tool guidance, kept in markdown so it reads like documentation. */
|
|
27
27
|
const GLOB_PROMPT = readFileSync(fileURLToPath(new URL("glob.md", import.meta.url)), "utf8").trim();
|
|
@@ -30,11 +30,6 @@ export function buildGlobArgs(pattern: string): string[] {
|
|
|
30
30
|
return ["--no-config", "--files", `--glob=${pattern}`, "--glob=!**/.git/**", "."];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** rg --files 输出的路径:去掉前导 `./`,分隔符统一成 `/`。 */
|
|
34
|
-
function normalizeRipgrepPath(text: string): string {
|
|
35
|
-
return text.replace(/^(?:\.[\\/])+/u, "").replaceAll("\\", "/");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
33
|
export function renderGlobOutput(files: readonly string[], truncated: boolean): string {
|
|
39
34
|
if (files.length === 0) {
|
|
40
35
|
return "No files found";
|
package/src/opencode/grep.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { parseWithSchema } from "../lib/parse-with-schema.js";
|
|
|
24
24
|
import type { ToolBus } from "../lib/tool-bus.js";
|
|
25
25
|
import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
|
|
26
26
|
import { didYouMean } from "./files.js";
|
|
27
|
-
import { RIPGREP_RESULT_LIMIT, runRipgrep } from "./ripgrep.js";
|
|
27
|
+
import { normalizeRipgrepPath, RIPGREP_RESULT_LIMIT, runRipgrep } from "./ripgrep.js";
|
|
28
28
|
|
|
29
29
|
/** Tool guidance, kept in markdown so it reads like documentation. */
|
|
30
30
|
const GREP_PROMPT = readFileSync(fileURLToPath(new URL("grep.md", import.meta.url)), "utf8").trim();
|
|
@@ -51,11 +51,6 @@ export interface GrepMatch {
|
|
|
51
51
|
text: string;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** rg 输出的路径:去掉前导 `./`,分隔符统一成 `/`。 */
|
|
55
|
-
function normalizeRipgrepPath(text: string): string {
|
|
56
|
-
return text.replace(/^(?:\.[\\/])+/u, "").replaceAll("\\", "/");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
54
|
/** 行文本:去掉行尾换行,超长截断(不留半个 surrogate pair)。 */
|
|
60
55
|
function normalizeLineText(text: string): string {
|
|
61
56
|
const line = text.replace(/[\r\n]+$/u, "");
|
package/src/opencode/ripgrep.ts
CHANGED
|
@@ -12,6 +12,11 @@ import { spawn } from "node:child_process";
|
|
|
12
12
|
/** 单次搜索最多返回的结果数(上游 limit = 100)。 */
|
|
13
13
|
export const RIPGREP_RESULT_LIMIT = 100;
|
|
14
14
|
|
|
15
|
+
/** rg 输出的路径:去掉前导 `./`,分隔符统一成 `/`。 */
|
|
16
|
+
export function normalizeRipgrepPath(text: string): string {
|
|
17
|
+
return text.replace(/^(?:\.[\\/])+/u, "").replaceAll("\\", "/");
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
/** stderr 最多保留的字节数(上游 ERROR_BYTES)。 */
|
|
16
21
|
const ERROR_BYTES = 8 * 1024;
|
|
17
22
|
|
package/src/talk/policy.ts
CHANGED
|
@@ -30,7 +30,7 @@ export class OutboundPolicy {
|
|
|
30
30
|
* definition worked through what it was handed). `target` scopes the
|
|
31
31
|
* identical-body dedupe to a single peer (loop-breaking).
|
|
32
32
|
*/
|
|
33
|
-
check(body: string, unreadBacklog: number, target
|
|
33
|
+
check(body: string, unreadBacklog: number, target = ""): OutboundVerdict {
|
|
34
34
|
if (body.length > MAX_BODY_CHARS) {
|
|
35
35
|
return {
|
|
36
36
|
ok: false,
|
|
@@ -44,7 +44,7 @@ export class OutboundPolicy {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
const now = this.now();
|
|
47
|
-
const dedupeKey = `${body}\u0000${target
|
|
47
|
+
const dedupeKey = `${body}\u0000${target}`;
|
|
48
48
|
const lastSame = this.recentBodies.get(dedupeKey);
|
|
49
49
|
if (lastSame !== undefined && now - lastSame < DEDUPE_WINDOW_MS) {
|
|
50
50
|
return {
|
|
@@ -66,9 +66,9 @@ export class OutboundPolicy {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** Record a successful send so dedupe/rate state stays current. */
|
|
69
|
-
recordSend(body: string, target
|
|
69
|
+
recordSend(body: string, target = ""): void {
|
|
70
70
|
this.sentAt.push(this.now());
|
|
71
|
-
this.recentBodies.set(`${body}\u0000${target
|
|
71
|
+
this.recentBodies.set(`${body}\u0000${target}`, this.now());
|
|
72
72
|
// bound the dedupe map
|
|
73
73
|
if (this.recentBodies.size <= 200) {
|
|
74
74
|
return;
|
package/src/web/fetch.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { parseHTML } from "linkedom";
|
|
|
21
21
|
import TurndownService from "turndown";
|
|
22
22
|
import { Type } from "typebox";
|
|
23
23
|
|
|
24
|
+
import { withTimeoutSignal } from "../lib/abort.js";
|
|
24
25
|
import { egress } from "../lib/egress.js";
|
|
25
26
|
import { resolvePathArg } from "../lib/path.js";
|
|
26
27
|
import { createRequestPolicy } from "../lib/request-policy.js";
|
|
@@ -120,11 +121,6 @@ export async function assertPublicHostname(
|
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
124
|
-
const timeout = AbortSignal.timeout(ms);
|
|
125
|
-
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
124
|
interface FetchedPage {
|
|
129
125
|
url: string;
|
|
130
126
|
title: string;
|
|
@@ -150,7 +146,7 @@ async function fetchWithRedirects(
|
|
|
150
146
|
await assertPublicHostname(current.hostname);
|
|
151
147
|
const response = await fetchFn(current, {
|
|
152
148
|
redirect: "manual",
|
|
153
|
-
signal:
|
|
149
|
+
signal: withTimeoutSignal(signal, TIMEOUT_MS),
|
|
154
150
|
headers: { "user-agent": "Mozilla/5.0 (compatible; pi-web-fetch/1.0)" },
|
|
155
151
|
});
|
|
156
152
|
const location = response.headers.get("location");
|
package/src/web/search.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
11
11
|
import { Type } from "typebox";
|
|
12
12
|
import { Value } from "typebox/value";
|
|
13
13
|
|
|
14
|
+
import { withTimeoutSignal } from "../lib/abort.js";
|
|
14
15
|
import { egress } from "../lib/egress.js";
|
|
15
16
|
import type { ToolBus } from "../lib/tool-bus.js";
|
|
16
17
|
import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
|
|
@@ -143,11 +144,6 @@ function mapHits(
|
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
/** 合并调用方 signal 与超时;调用方未传时仍有超时兜底 */
|
|
146
|
-
function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
147
|
-
const timeout = AbortSignal.timeout(ms);
|
|
148
|
-
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
147
|
export async function searchWeb(
|
|
152
148
|
query: string,
|
|
153
149
|
apiKey: string,
|
|
@@ -160,7 +156,7 @@ export async function searchWeb(
|
|
|
160
156
|
"Content-Type": "application/json",
|
|
161
157
|
},
|
|
162
158
|
body: JSON.stringify(buildSearchBody(query, options)),
|
|
163
|
-
signal:
|
|
159
|
+
signal: withTimeoutSignal(options.signal, TIMEOUT_MS),
|
|
164
160
|
});
|
|
165
161
|
|
|
166
162
|
const raw = await response.text();
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* orphan-worker-cleaner —— 宿主消失时让 pi SDK worker 自行退出
|
|
3
|
-
*
|
|
4
|
-
* VS Code 的 pi 扩展(Pendant)用 fork 启动 dist/pi-sdk-worker.cjs 承载 pi SDK。
|
|
5
|
-
* 宿主进程消失时 worker 不会退出:worker 侧只把 IPC 断连当成可重试的传输错误
|
|
6
|
-
* 上报,既不清理也不自杀,于是留下 ppid=1 的孤儿,连同它拉起的 language server
|
|
7
|
-
* (实测单个 worker 身上挂 4 GiB 上下)一起常驻。
|
|
8
|
-
*
|
|
9
|
-
* 这里只做一件事:定时看一眼自己的父进程还在不在(父进程退出后会被 init 收养,
|
|
10
|
-
* ppid 变成 1),不在就退出。worker 一退,它拉起的 language server 会因为 stdin
|
|
11
|
-
* 关闭自行退出,language server 退出时(process.on("exit") → shutdown())又会
|
|
12
|
-
* kill 掉自己 fork 的 tsserver,整棵树随之消失 —— 所以不需要遍历或清理别的进程。
|
|
13
|
-
*
|
|
14
|
-
* 只对 fork 出来的 SDK worker 生效(process.channel 存在):CLI 直跑、以及 pi
|
|
15
|
-
* 内部 spawn 出来的进程都没有 IPC channel,不在此列,不会被误伤。
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
-
|
|
20
|
-
/** 检查父进程是否还在的间隔 */
|
|
21
|
-
export const DEFAULT_CHECK_INTERVAL_MS = 5_000;
|
|
22
|
-
|
|
23
|
-
export default function orphanWorkerCleaner(pi: ExtensionAPI): void {
|
|
24
|
-
if (process.channel === undefined) {
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
const timer = setInterval(() => {
|
|
28
|
-
if (process.ppid === 1) {
|
|
29
|
-
// eslint-disable-next-line unicorn/no-process-exit -- 宿主已经没了,worker 必须整体退出
|
|
30
|
-
process.exit(0);
|
|
31
|
-
}
|
|
32
|
-
}, DEFAULT_CHECK_INTERVAL_MS);
|
|
33
|
-
timer.unref();
|
|
34
|
-
|
|
35
|
-
pi.on("session_shutdown", () => {
|
|
36
|
-
clearInterval(timer);
|
|
37
|
-
});
|
|
38
|
-
}
|