@trim21/personal-pi-extensions 0.1.690 → 0.1.692

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.
Files changed (44) hide show
  1. package/README.md +11 -3
  2. package/package.json +1 -1
  3. package/src/bwrap/dcg-scan.ts +2 -8
  4. package/src/bwrap/html.ts +13 -0
  5. package/src/bwrap/runtime.ts +1 -8
  6. package/src/claude-code/shell.ts +12 -42
  7. package/src/codemode/declarations.ts +3 -8
  8. package/src/codemode/tool.ts +15 -6
  9. package/src/gh/base.ts +91 -23
  10. package/src/gh/index.ts +8 -5
  11. package/src/gh/tools/download-release-assets.ts +6 -3
  12. package/src/gh/tools/get-workflow-jobs.ts +4 -7
  13. package/src/gh/tools/list-issues.ts +6 -39
  14. package/src/gh/tools/list-prs.ts +6 -39
  15. package/src/gh/tools/list-releases.ts +6 -9
  16. package/src/gh/tools/list-workflow-runs.ts +6 -9
  17. package/src/gh/tools/read-ci-logs.ts +8 -19
  18. package/src/gh/tools/read-issue-comments.ts +4 -12
  19. package/src/gh/tools/read-issue.ts +4 -12
  20. package/src/gh/tools/read-pr-comments.ts +6 -14
  21. package/src/gh/tools/read-pr-diff.ts +8 -8
  22. package/src/gh/tools/read-pr-status.ts +6 -4
  23. package/src/gh/tools/read-pr.ts +4 -12
  24. package/src/gh/tools/read-release.ts +5 -8
  25. package/src/gh/tools/read-repo.ts +5 -9
  26. package/src/gh/tools/wait-commit-checks.ts +2 -4
  27. package/src/gh/tools/wait-pr-checks.ts +2 -4
  28. package/src/gh/tools/watch-run.ts +7 -4
  29. package/src/index.ts +3 -0
  30. package/src/lib/abort.ts +10 -0
  31. package/src/lib/bash-tool.ts +48 -0
  32. package/src/lib/github-reads.ts +3 -23
  33. package/src/lib/github.ts +81 -41
  34. package/src/lib/model-call.ts +3 -7
  35. package/src/lib/tool-bus.ts +30 -2
  36. package/src/lib/tool-registration.ts +6 -0
  37. package/src/lib/tools-config.ts +24 -7
  38. package/src/opencode/bash.ts +11 -42
  39. package/src/opencode/glob.ts +1 -6
  40. package/src/opencode/grep.ts +1 -6
  41. package/src/opencode/ripgrep.ts +5 -0
  42. package/src/web/fetch.ts +2 -6
  43. package/src/web/search.ts +2 -6
  44. package/src/orphan-worker-cleaner.ts +0 -38
@@ -15,7 +15,7 @@ import { pipeline } from "node:stream/promises";
15
15
 
16
16
  import type { Octokit } from "octokit";
17
17
 
18
- import { createGithubApi, type GithubClientOptions } from "./github.js";
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(options: GithubClientOptions = {}): GithubReads {
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
- * Error thrown when the GitHub search API rejects the request. Carries the
274
- * original toolcall params so the model can see the exact input.
273
+ * GitHub 请求失败:octokit 调用与非 JSON 的原始下载共用这一个类型,调用方据此区分
274
+ * 「不存在」(`isNotFound`)与真失败,不用再各自去剥错误对象上的 status。
275
+ *
276
+ * `input` 是触发该请求的工具参数;带上它,模型才能在报错里看到自己给的输入。
275
277
  */
276
- class GithubSearchError extends Error {
277
- readonly params: SearchParams;
278
+ export class GithubApiError extends Error {
279
+ /** HTTP 状态;连响应都没拿到(网络错误)时为 undefined。 */
278
280
  readonly status: number | undefined;
279
-
280
- constructor(message: string, params: SearchParams, status?: number) {
281
- super(`${message} (input: ${JSON.stringify(params)})`);
282
- this.name = "GithubSearchError";
283
- this.params = params;
284
- this.status = status;
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
- /** Run an octokit request; retries once with a fresh token on 401. */
303
- call<T>(fn: (octokit: Octokit) => Promise<T>): Promise<T>;
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 as { status?: number }).status;
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
- throw error;
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 backed by a cached octokit instance.
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(options: GithubClientOptions = {}): GithubSearch {
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
- try {
407
- // `@me` 是 gh CLI 的简写,REST 搜索不认识:这里不展开,带关键词时它按字面量
408
- // 进入查询串(无关键词的列表走 gh CLI,由 gh 自己展开)。
409
- const q = buildSearchQuery(kind, { ...params, limit });
410
- return await api.call(async (client) => {
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
- } catch (error) {
418
- const status = (error as { status?: number }).status;
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, backed by a cached octokit instance.
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(options: GithubClientOptions = {}): GithubChecksClient {
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) =>
@@ -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: withTimeout(options.signal, options.timeoutMs),
78
+ signal: withTimeoutSignal(options.signal, options.timeoutMs),
83
79
  },
84
80
  );
85
81
  const text = contentText(result.content).trim();
@@ -3,7 +3,9 @@
3
3
  * (按名执行,不必绕回模型)。
4
4
  *
5
5
  * - `register`:按配置过滤(被禁用时跳过并返回 false)后交给 `pi.registerTool`,
6
- * 并保留通过的定义供 `list` / `get` 查询。
6
+ * 并保留通过的定义供 `list` / `get` 查询。命中 codemode-only 且声明了
7
+ * `structuredSchema` 的工具只保留在总线上、不交给 pi(标 `codemodeOnly: true`);
8
+ * 命中但没有 schema 的工具照常注册,并在 `diagnostics` 里留一条说明。
7
9
  * - `executeTool`:先用工具自身的参数 schema 校验参数,再调用定义的 `execute`;
8
10
  * 参数校验失败与工具抛出的异常都归一化成错误结果返回,不向调用方抛异常。
9
11
  *
@@ -85,9 +87,10 @@ export interface ExecuteToolOptions {
85
87
  toolCallId?: string;
86
88
  }
87
89
 
88
- /** 注册后的定义:多出的 `structuredSchema` 供 codemode 渲染脚本侧返回类型。 */
90
+ /** 注册后的定义:多出的 `structuredSchema` 供 codemode 渲染脚本侧返回类型,`codemodeOnly` 标出未交给 pi 的只读脚本工具。 */
89
91
  export type RegisteredToolDefinition = ToolDefinition<TSchema, unknown, unknown> & {
90
92
  structuredSchema?: TSchema;
93
+ codemodeOnly?: boolean;
91
94
  };
92
95
 
93
96
  export interface ToolBus {
@@ -99,6 +102,8 @@ export interface ToolBus {
99
102
  list(): readonly RegisteredToolDefinition[];
100
103
  /** 所有尝试注册过的工具名(含被禁用而跳过的),用于校验配置里的模式是否有效。 */
101
104
  declaredNames(): readonly string[];
105
+ /** 注册期发现的可诊断问题(去重),由调用方在能提示用户时上报。 */
106
+ diagnostics(): readonly string[];
102
107
  get(name: string): RegisteredToolDefinition | undefined;
103
108
  /** 按名执行一个已注册工具;任何失败都以 `isError` 结果返回,不抛异常。 */
104
109
  executeTool(
@@ -111,6 +116,8 @@ export interface ToolBus {
111
116
  export interface ToolBusOptions {
112
117
  /** 返回 true 表示该工具被禁用、不注册。 */
113
118
  isDisabled?: (name: string) => boolean;
119
+ /** 返回 true 表示该工具应只进 codemode(是否真的如此还要看它有没有声明 `structuredSchema`)。 */
120
+ isCodemodeOnly?: (name: string) => boolean;
114
121
  }
115
122
 
116
123
  function errorResult(text: string): ToolExecutionResult {
@@ -180,6 +187,7 @@ function checkStructuredResult(
180
187
  export function createToolBus(pi: ExtensionAPI, options: ToolBusOptions = {}): ToolBus {
181
188
  const registered = new Map<string, RegisteredToolDefinition>();
182
189
  const declared = new Set<string>();
190
+ const diagnostics = new Set<string>();
183
191
  let callCounter = 0;
184
192
 
185
193
  return {
@@ -188,6 +196,22 @@ export function createToolBus(pi: ExtensionAPI, options: ToolBusOptions = {}): T
188
196
  if (options.isDisabled?.(definition.name) === true) {
189
197
  return false;
190
198
  }
199
+ if (options.isCodemodeOnly?.(definition.name) === true) {
200
+ // schema 门控:进不了 codemode 的工具照常直接注册,只留一条可诊断的说明,
201
+ // 避免配置把工具从模型面前静默移除(见 openspec design.md)。
202
+ if ((definition as { structuredSchema?: unknown }).structuredSchema === undefined) {
203
+ diagnostics.add(
204
+ `personalExtensions.codemodeOnlyTools: tool "${definition.name}" declares no structuredSchema; it stays directly available.`,
205
+ );
206
+ } else {
207
+ const codemodeStored = {
208
+ ...(definition as unknown as RegisteredToolDefinition),
209
+ codemodeOnly: true,
210
+ };
211
+ registered.set(codemodeStored.name, codemodeStored);
212
+ return true;
213
+ }
214
+ }
191
215
  const stored = definition as unknown as RegisteredToolDefinition;
192
216
  pi.registerTool(stored);
193
217
  registered.set(stored.name, stored);
@@ -202,6 +226,10 @@ export function createToolBus(pi: ExtensionAPI, options: ToolBusOptions = {}): T
202
226
  return [...declared];
203
227
  },
204
228
 
229
+ diagnostics() {
230
+ return [...diagnostics];
231
+ },
232
+
205
233
  get(name) {
206
234
  return registered.get(name);
207
235
  },
@@ -41,6 +41,8 @@ export interface ToolRegistration {
41
41
  onSessionStart(register: (bus: ToolBus, ctx: ExtensionContext) => void | Promise<void>): void;
42
42
  /** 哪些配置模式没命中过任何工具(在注册完成后调用)。 */
43
43
  unmatchedPatterns(): UnmatchedPattern[];
44
+ /** 注册期发现的可诊断问题(去重),与 unmatchedPatterns 一样在注册完成后上报。 */
45
+ diagnostics(): readonly string[];
44
46
  }
45
47
 
46
48
  /**
@@ -62,6 +64,7 @@ export function createToolRegistration(pi: ExtensionAPI, settingsPath?: string):
62
64
  // 每次注册都按当前模型重新判定:工具在 session_start 里注册,模型由下面
63
65
  // 的 handler 更新,二者在同一个会话内保持一致。
64
66
  isDisabled: (name) => resolveToolAvailability(config, model).isDisabled(name),
67
+ isCodemodeOnly: (name) => resolveToolAvailability(config, model).isCodemodeOnly(name),
65
68
  });
66
69
 
67
70
  pi.on("session_start", (_event, ctx) => {
@@ -79,5 +82,8 @@ export function createToolRegistration(pi: ExtensionAPI, settingsPath?: string):
79
82
  unmatchedPatterns() {
80
83
  return resolveToolAvailability(config, model).unmatchedPatterns(bus.declaredNames());
81
84
  },
85
+ diagnostics() {
86
+ return bus.diagnostics();
87
+ },
82
88
  };
83
89
  }
@@ -7,7 +7,8 @@
7
7
  * "fileIo": "claude-code",
8
8
  * "fileIoByModel": [{ "models": ["glm-*"], "fileIo": "opencode" }],
9
9
  * "disabledTools": ["talk-*", { "tools": ["web_*"], "models": ["gpt-*"] }],
10
- * "enabledTools": [{ "tools": ["web_search"], "models": ["glm-*"] }]
10
+ * "enabledTools": [{ "tools": ["web_search"], "models": ["glm-*"] }],
11
+ * "codemodeOnlyTools": [{ "tools": ["read-github-*"], "models": ["glm-*"] }]
11
12
  * }
12
13
  * }
13
14
  * ```
@@ -15,6 +16,9 @@
15
16
  * 工具名与模型名都用 minimatch 通配模式匹配;模型名同时试 `model.id` 与
16
17
  * `provider/model` 两种写法。带 `models` 的条目只在当前模型命中时才参与判定。
17
18
  * 非法字段/条目被忽略并产出警告,不影响其余配置生效。
19
+ *
20
+ * `codemodeOnlyTools` 只表示曝光方式:命中它的工具由总线决定是否按 codemode-only
21
+ * 注册(还要看工具有没有声明 `structuredSchema`,见 `tool-bus.ts`)。
18
22
  */
19
23
 
20
24
  import { readFileSync } from "node:fs";
@@ -25,6 +29,8 @@ import { minimatch } from "minimatch";
25
29
  import { Type } from "typebox";
26
30
  import { Value } from "typebox/value";
27
31
 
32
+ import { isRecord } from "./narrow.js";
33
+
28
34
  // ── schema ───────────────────────────────────────────────────────────────────
29
35
 
30
36
  const fileIoSchema = Type.Union([Type.Literal("claude-code"), Type.Literal("opencode")]);
@@ -43,7 +49,7 @@ const toolRuleSchema = Type.Object({
43
49
 
44
50
  export type FileIoToolset = "claude-code" | "opencode";
45
51
 
46
- export type ToolRuleField = "disabledTools" | "enabledTools";
52
+ export type ToolRuleField = "disabledTools" | "enabledTools" | "codemodeOnlyTools";
47
53
 
48
54
  export interface FileIoByModelEntry {
49
55
  models: readonly string[];
@@ -63,6 +69,8 @@ export interface ToolsConfig {
63
69
  fileIoByModel: readonly FileIoByModelEntry[];
64
70
  disabledTools: readonly ToolPatternRule[];
65
71
  enabledTools: readonly ToolPatternRule[];
72
+ /** 命中且声明了 `structuredSchema` 的工具只进 codemode、不交给 pi。 */
73
+ codemodeOnlyTools: readonly ToolPatternRule[];
66
74
  /** 解析阶段发现的问题,由调用方在能提示用户时上报。 */
67
75
  warnings: readonly string[];
68
76
  }
@@ -80,6 +88,8 @@ export interface UnmatchedPattern {
80
88
  export interface ToolAvailability {
81
89
  /** 该工具名是否被禁用(命中 disabledTools 且未命中 enabledTools)。 */
82
90
  isDisabled(name: string): boolean;
91
+ /** 该工具名是否命中 codemodeOnlyTools(是否真的按 codemode-only 注册还要看它有没有结构化输出)。 */
92
+ isCodemodeOnly(name: string): boolean;
83
93
  /** 哪些模式没匹配到任何工具(含被规则禁用的工具)。 */
84
94
  unmatchedPatterns(declaredNames: readonly string[]): UnmatchedPattern[];
85
95
  }
@@ -88,10 +98,6 @@ export const DEFAULT_FILE_IO: FileIoToolset = "claude-code";
88
98
 
89
99
  // ── 读取与解析 ───────────────────────────────────────────────────────────────
90
100
 
91
- function isRecord(value: unknown): value is Record<string, unknown> {
92
- return typeof value === "object" && value !== null && !Array.isArray(value);
93
- }
94
-
95
101
  function parseFileIo(value: unknown, field: string, warnings: string[]): FileIoToolset | undefined {
96
102
  if (value === undefined) {
97
103
  return undefined;
@@ -167,6 +173,7 @@ export function parseToolsConfig(section: unknown, source = "personalExtensions"
167
173
  fileIoByModel: [],
168
174
  disabledTools: [],
169
175
  enabledTools: [],
176
+ codemodeOnlyTools: [],
170
177
  warnings,
171
178
  };
172
179
  }
@@ -177,6 +184,7 @@ export function parseToolsConfig(section: unknown, source = "personalExtensions"
177
184
  fileIoByModel: [],
178
185
  disabledTools: [],
179
186
  enabledTools: [],
187
+ codemodeOnlyTools: [],
180
188
  warnings,
181
189
  };
182
190
  }
@@ -186,6 +194,11 @@ export function parseToolsConfig(section: unknown, source = "personalExtensions"
186
194
  fileIoByModel: parseFileIoByModel(section.fileIoByModel, `${source}.fileIoByModel`, warnings),
187
195
  disabledTools: parseToolRules(section.disabledTools, `${source}.disabledTools`, warnings),
188
196
  enabledTools: parseToolRules(section.enabledTools, `${source}.enabledTools`, warnings),
197
+ codemodeOnlyTools: parseToolRules(
198
+ section.codemodeOnlyTools,
199
+ `${source}.codemodeOnlyTools`,
200
+ warnings,
201
+ ),
189
202
  warnings,
190
203
  };
191
204
  }
@@ -260,9 +273,13 @@ export function resolveToolAvailability(
260
273
  return config.enabledTools.every((rule) => !ruleMatches(rule, name, model));
261
274
  },
262
275
 
276
+ isCodemodeOnly(name) {
277
+ return config.codemodeOnlyTools.some((rule) => ruleMatches(rule, name, model));
278
+ },
279
+
263
280
  unmatchedPatterns(declaredNames) {
264
281
  const unmatched: UnmatchedPattern[] = [];
265
- for (const field of ["disabledTools", "enabledTools"] as const) {
282
+ for (const field of ["disabledTools", "enabledTools", "codemodeOnlyTools"] as const) {
266
283
  for (const rule of config[field]) {
267
284
  for (const pattern of rule.tools) {
268
285
  if (declaredNames.every((name) => !matchesPattern(pattern, name))) {
@@ -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 ?? DEFAULT_TIMEOUT_MS;
131
- if (!Number.isFinite(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS) {
132
- throw new Error(`timeout must be between 1 and ${MAX_TIMEOUT_MS} milliseconds`);
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 fullOutput(
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 fullOutput(result.output, result.truncation, result.fullOutputPath),
180
+ output: await spilledOutput(result.output, result.truncation, result.fullOutputPath),
212
181
  },
213
182
  },
214
183
  };
@@ -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";
@@ -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, "");
@@ -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