@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.
Files changed (48) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/src/bwrap/dcg-scan.ts +2 -8
  4. package/src/bwrap/html.ts +13 -0
  5. package/src/bwrap/network-stack.ts +2 -1
  6. package/src/bwrap/runtime.ts +1 -8
  7. package/src/claude-code/grep.ts +2 -1
  8. package/src/claude-code/shell.ts +13 -42
  9. package/src/codemode/declarations.ts +3 -8
  10. package/src/codemode/worker.js +11 -7
  11. package/src/codemode/worker.ts +10 -6
  12. package/src/gh/base.ts +91 -23
  13. package/src/gh/index.ts +8 -5
  14. package/src/gh/tools/download-release-assets.ts +6 -3
  15. package/src/gh/tools/get-workflow-jobs.ts +4 -7
  16. package/src/gh/tools/list-issues.ts +6 -39
  17. package/src/gh/tools/list-prs.ts +6 -39
  18. package/src/gh/tools/list-releases.ts +6 -9
  19. package/src/gh/tools/list-workflow-runs.ts +6 -9
  20. package/src/gh/tools/read-ci-logs.ts +8 -19
  21. package/src/gh/tools/read-issue-comments.ts +4 -12
  22. package/src/gh/tools/read-issue.ts +4 -12
  23. package/src/gh/tools/read-pr-comments.ts +6 -14
  24. package/src/gh/tools/read-pr-diff.ts +8 -8
  25. package/src/gh/tools/read-pr-status.ts +6 -4
  26. package/src/gh/tools/read-pr.ts +4 -12
  27. package/src/gh/tools/read-release.ts +5 -8
  28. package/src/gh/tools/read-repo.ts +5 -9
  29. package/src/gh/tools/wait-commit-checks.ts +2 -4
  30. package/src/gh/tools/wait-pr-checks.ts +2 -4
  31. package/src/gh/tools/watch-run.ts +7 -4
  32. package/src/lib/abort.ts +10 -0
  33. package/src/lib/bash-tool.ts +48 -0
  34. package/src/lib/github-reads.ts +3 -23
  35. package/src/lib/github.ts +81 -41
  36. package/src/lib/lsp/client.ts +1 -1
  37. package/src/lib/lsp/server-config.ts +1 -0
  38. package/src/lib/model-call.ts +3 -7
  39. package/src/lib/tools-config.ts +2 -4
  40. package/src/opencode/bash.ts +11 -42
  41. package/src/opencode/files.ts +1 -0
  42. package/src/opencode/glob.ts +1 -6
  43. package/src/opencode/grep.ts +1 -6
  44. package/src/opencode/ripgrep.ts +5 -0
  45. package/src/talk/policy.ts +4 -4
  46. package/src/web/fetch.ts +2 -6
  47. package/src/web/search.ts +2 -6
  48. package/src/orphan-worker-cleaner.ts +0 -38
package/README.md CHANGED
@@ -461,20 +461,20 @@ pi -e ./src/aft/index.ts
461
461
 
462
462
  ## gh-readonly
463
463
 
464
- GitHub 只读工具集。取数走 octokit REST(`src/lib/github-reads.ts`);只有「当前仓库」解析(`gh repo view`)与 token 获取(`gh auth token`)还用系统 [`gh`](https://cli.github.com/),因此 `gh` 不在 PATH 时整组不注册并在 session_start 报错;Windows 禁用。
464
+ GitHub 只读工具集。取数全部走 octokit REST(`src/lib/github-reads.ts` 的读取层 + `src/lib/github.ts` 的 accessor);只有「当前仓库」解析(`gh repo view`)与 token 获取(`gh auth token`)还用系统 [`gh`](https://cli.github.com/),因此 `gh` 不在 PATH 时整组不注册并在 session_start 报错;Windows 禁用。一个会话只 spawn 一次 `gh auth token`:三个子客户端(search / checks / reads)共用同一个 accessor。
465
465
 
466
466
  - **Issue / PR**:`read-github-issue`、`list-github-issues`、`read-github-issue-comments`、`read-github-pr`、`list-github-prs`、`read-github-pr-diff`、`read-github-pr-status`、`read-github-pr-comments`
467
467
  - **CI**:`read-github-ci-logs`、`list-github-workflow-runs`、`get-github-workflow-jobs`、`wait-github-pr-checks`、`wait-github-commit-checks`、`watch-github-run`
468
468
  - **仓库 / 发布**:`read-github-repo`、`list-github-releases`、`read-github-release`、`download-github-release-assets`
469
469
 
470
- `list-github-issues` / `list-github-prs` 带 `keywords` 时走 GitHub search API(state 缺省只搜 open,`state: "all"` 不加 state qualifier 覆盖 open + closed),输出 TSV,默认列 `number,state,title,labels,updatedAt`,可用 `fields` 白名单指定列。`wait-github-pr-checks` / `wait-github-commit-checks` 30 秒轮询、600 秒截止:任一 fail 即返回,全部 pass / skipped 才算通过,超时返回快照不抛错。
470
+ `list-github-issues` / `list-github-prs` 带 `keywords` 时走 GitHub search API,不带 `keywords` 时走 REST 列表端点(state 缺省只搜 open,`state: "all"` 不加 state qualifier 覆盖 open + closed),输出 TSV,默认列 `number,state,title,labels,updatedAt`,可用 `fields` 白名单指定列。`wait-github-pr-checks` / `wait-github-commit-checks` 30 秒轮询、600 秒截止:任一 fail 即返回,全部 pass / skipped 才算通过,超时返回快照不抛错。
471
471
 
472
472
  出网代理:`~/.pi/agent/proxy.json`(`{ "proxy": "http://127.0.0.1:7890", "noProxy": "localhost" }`),缺省字段回退 `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` 环境变量。
473
473
 
474
474
  ### 使用
475
475
 
476
476
  ```bash
477
- pi -e ./src/gh-readonly.ts
477
+ pi -e ./src/gh/index.ts
478
478
  ```
479
479
 
480
480
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.689",
3
+ "version": "0.1.691",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -53,7 +53,7 @@
53
53
  "eslint-plugin-import-x": "^4.17.1",
54
54
  "eslint-plugin-promise": "7.3.0",
55
55
  "eslint-plugin-simple-import-sort": "14.0.0",
56
- "eslint-plugin-unicorn": "76.0.0",
56
+ "eslint-plugin-unicorn": "77.0.0",
57
57
  "eslint-plugin-unused-imports": "4.4.1",
58
58
  "husky": "^9.1.7",
59
59
  "jiti": "^2.7.0",
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import { spawn } from "node:child_process";
9
9
 
10
+ import { escapeHtml } from "./html.js";
11
+
10
12
  /** `dcg test --format json` 输出中我们关心的字段。 */
11
13
  interface DcgTestOutput {
12
14
  decision?: "allow" | "deny" | "indeterminate";
@@ -30,14 +32,6 @@ export type DcgScanOutcome =
30
32
  /** dcg 扫描的超时预算:超时视为无建议,不让审批弹窗被拖住。 */
31
33
  const DCG_SCAN_TIMEOUT_MS = 2000;
32
34
 
33
- function escapeHtml(text: string): string {
34
- return text
35
- .replaceAll("&", "&")
36
- .replaceAll("<", "&lt;")
37
- .replaceAll(">", "&gt;")
38
- .replaceAll('"', "&quot;");
39
- }
40
-
41
35
  /**
42
36
  * 对命令做 dcg 扫描。返回三种结果:
43
37
  * - `suggestion`:正常判定,携带建议文本
@@ -0,0 +1,13 @@
1
+ /**
2
+ * UI 文本的 HTML 转义。
3
+ *
4
+ * bwrap 的审批弹窗把外部文本(dcg 建议、命令、路径)直接拼进 HTML description,
5
+ * 这些文本必须先转义,否则会成为弹窗里的标签/属性。
6
+ */
7
+ export function escapeHtml(text: string): string {
8
+ return text
9
+ .replaceAll("&", "&amp;")
10
+ .replaceAll("<", "&lt;")
11
+ .replaceAll(">", "&gt;")
12
+ .replaceAll('"', "&quot;");
13
+ }
@@ -85,7 +85,8 @@ function killHolder(pid: number | undefined): void {
85
85
  }
86
86
 
87
87
  /** 轮询等待进程退出(进程消失即返回)。 */
88
- async function waitForExit(pid: number, timeoutMs = 2000): Promise<void> {
88
+ async function waitForExit(pid: number): Promise<void> {
89
+ const timeoutMs = 2000;
89
90
  const deadline = Date.now() + timeoutMs;
90
91
  while (Date.now() < deadline) {
91
92
  try {
@@ -43,6 +43,7 @@ import {
43
43
  type ResolvedBwrap,
44
44
  } from "./core.js";
45
45
  import { dcgSuggestion } from "./dcg-scan.js";
46
+ import { escapeHtml } from "./html.js";
46
47
  import { loadSandboxConfig, runInSandbox } from "./sandbox.js";
47
48
 
48
49
  /** 全权限审批对话框的选项 label(也作为 switch 匹配键与测试引用)。 */
@@ -155,14 +156,6 @@ export function formatElapsedSeconds(elapsedMs: number): string {
155
156
  return `${(elapsedMs / 1000).toFixed(1)} seconds`;
156
157
  }
157
158
 
158
- function escapeHtml(text: string): string {
159
- return text
160
- .replaceAll("&", "&amp;")
161
- .replaceAll("<", "&lt;")
162
- .replaceAll(">", "&gt;")
163
- .replaceAll('"', "&quot;");
164
- }
165
-
166
159
  /** 进度推送的节流间隔(对齐 pi 内置 bash 工具的 100ms)。 */
167
160
  const BASH_UPDATE_THROTTLE_MS = 100;
168
161
  /** 进度快照只保留尾部内容,避免大输出每 100ms 全量推给 TUI。 */
@@ -36,7 +36,8 @@ const VCS_DIRECTORIES_TO_EXCLUDE = [".git", ".svn", ".hg", ".bzr", ".jj", ".sl"]
36
36
  */
37
37
  const DEFAULT_HEAD_LIMIT = 250;
38
38
 
39
- function truncateOutput(output: string, maxCharacters = 30_000): string {
39
+ function truncateOutput(output: string): string {
40
+ const maxCharacters = 30_000;
40
41
  if (output.length <= maxCharacters) {
41
42
  return output;
42
43
  }
@@ -16,12 +16,15 @@ import {
16
16
  formatElapsedSeconds,
17
17
  sandboxHintBlock,
18
18
  } from "../bwrap/runtime.js";
19
+ import {
20
+ BASH_DEFAULT_TIMEOUT_MS,
21
+ BASH_MAX_TIMEOUT_MS,
22
+ bashStructuredSchema,
23
+ spilledOutput,
24
+ } from "../lib/bash-tool.js";
19
25
  import { resolveWorkdir } from "../lib/path.js";
20
26
  import { defineStructuredTool, type ToolBus } from "../lib/tool-bus.js";
21
27
 
22
- const DEFAULT_TIMEOUT_MS = 120_000;
23
- const MAX_TIMEOUT_MS = 7_200_000;
24
-
25
28
  /** 对齐 Claude Code formatError:错误文本超过该长度时头尾各保留一半。 */
26
29
  const MAX_ERROR_CHARS = 10_000;
27
30
 
@@ -66,39 +69,6 @@ function appendTruncationNotice(
66
69
  return `${text}\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${fullOutputPath}]`;
67
70
  }
68
71
 
69
- /**
70
- * `Bash` 的结构化结果:只有退出码与完整输出——命令非零退出不是失败,所以载荷里没有成败
71
- * 标志,脚本直接读 `exitCode` 分支;超时/中止时 `exitCode` 为 `null`(原因在模型侧文本里)。
72
- */
73
- const bashStructuredSchema = Type.Object({
74
- exitCode: Type.Union([Type.Number(), Type.Null()], {
75
- description: "Exit code of the command; null when it was killed (timeout or abort)",
76
- }),
77
- output: Type.String({
78
- description:
79
- "Complete output of the command (stdout and stderr merged). Never truncated, never mixed with tool-added notices.",
80
- }),
81
- });
82
-
83
- /**
84
- * 载荷里的输出:文本被截断时读回落盘的完整输出(读不到就退回那份截断文本),
85
- * 因此脚本拿到的永远是命令真正输出的内容。
86
- */
87
- async function fullOutput(
88
- output: string,
89
- truncation: TruncationResult,
90
- spillPath: string | undefined,
91
- ): Promise<string> {
92
- if (spillPath === undefined || !truncation.truncated) {
93
- return output;
94
- }
95
- try {
96
- return await readFile(spillPath, "utf8");
97
- } catch {
98
- return output;
99
- }
100
- }
101
-
102
72
  /**
103
73
  * 成功路径:消费 runtime 的截断结果(输出已由 runtime 截断并落盘),
104
74
  * 截断时追加 `[Showing lines X-Y of N. Full output: path]` 提示。
@@ -108,6 +78,7 @@ function formatBashSuccess(result: Awaited<ReturnType<BwrapRuntime["execute"]>>)
108
78
  content: { type: "text"; text: string }[];
109
79
  details: BashToolDetails | undefined;
110
80
  } {
81
+ // eslint-disable-next-line unicorn/prefer-default-parameters -- output 是 string,空输出需显示 "(no output)",默认参数只覆盖 undefined
111
82
  const { output, truncation, fullOutputPath } = result;
112
83
  const text = appendTruncationNotice(output || "(no output)", truncation, fullOutputPath);
113
84
  return {
@@ -157,9 +128,9 @@ export function registerShellTools(bus: ToolBus, pi: ExtensionAPI, runtime: Bwra
157
128
  { additionalProperties: false },
158
129
  ),
159
130
  async execute(id, params, signal, onUpdate, ctx) {
160
- const timeout = params.timeout ?? DEFAULT_TIMEOUT_MS;
161
- if (!Number.isFinite(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS) {
162
- throw new Error(`timeout must be between 1 and ${MAX_TIMEOUT_MS} milliseconds`);
131
+ const timeout = params.timeout ?? BASH_DEFAULT_TIMEOUT_MS;
132
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > BASH_MAX_TIMEOUT_MS) {
133
+ throw new Error(`timeout must be between 1 and ${BASH_MAX_TIMEOUT_MS} milliseconds`);
163
134
  }
164
135
 
165
136
  const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
@@ -202,7 +173,7 @@ export function registerShellTools(bus: ToolBus, pi: ExtensionAPI, runtime: Bwra
202
173
  ok: true as const,
203
174
  value: {
204
175
  exitCode: null,
205
- output: await fullOutput(
176
+ output: await spilledOutput(
206
177
  error.partial.output,
207
178
  error.partial.truncation,
208
179
  error.partial.fullOutputPath,
@@ -225,7 +196,7 @@ export function registerShellTools(bus: ToolBus, pi: ExtensionAPI, runtime: Bwra
225
196
  ok: true as const,
226
197
  value: {
227
198
  exitCode: null,
228
- output: await fullOutput(
199
+ output: await spilledOutput(
229
200
  error.partial.output,
230
201
  error.partial.truncation,
231
202
  error.partial.fullOutputPath,
@@ -263,7 +234,7 @@ export function registerShellTools(bus: ToolBus, pi: ExtensionAPI, runtime: Bwra
263
234
  ok: true as const,
264
235
  value: {
265
236
  exitCode: result.exitCode,
266
- output: await fullOutput(result.output, result.truncation, result.fullOutputPath),
237
+ output: await spilledOutput(result.output, result.truncation, result.fullOutputPath),
267
238
  },
268
239
  },
269
240
  };
@@ -6,6 +6,7 @@
6
6
  * const / union / enum),其余退化成 `unknown`——宁可少给类型,也不要编出错类型。
7
7
  */
8
8
 
9
+ import { isRecord } from "../lib/narrow.js";
9
10
  import type { ScriptTool } from "./protocol.js";
10
11
 
11
12
  interface ToolLike {
@@ -16,12 +17,6 @@ interface ToolLike {
16
17
  structuredSchema: unknown;
17
18
  }
18
19
 
19
- type JsonSchema = Record<string, unknown>;
20
-
21
- function isSchema(value: unknown): value is JsonSchema {
22
- return typeof value === "object" && value !== null && !Array.isArray(value);
23
- }
24
-
25
20
  function literal(value: unknown): string {
26
21
  return typeof value === "string" ? JSON.stringify(value) : String(value);
27
22
  }
@@ -32,7 +27,7 @@ function propertyName(name: string): string {
32
27
  }
33
28
 
34
29
  function renderType(schema: unknown, indent: string): string {
35
- if (!isSchema(schema)) {
30
+ if (!isRecord(schema)) {
36
31
  return "unknown";
37
32
  }
38
33
  // TypeBox 的 Type.Literal / StringEnum 产出 const,而不是 enum
@@ -64,7 +59,7 @@ function renderType(schema: unknown, indent: string): string {
64
59
  return `Array<${renderType(schema.items, indent)}>`;
65
60
  }
66
61
  case "object": {
67
- const properties = isSchema(schema.properties) ? schema.properties : {};
62
+ const properties = isRecord(schema.properties) ? schema.properties : {};
68
63
  const required = new Set(Array.isArray(schema.required) ? (schema.required as string[]) : []);
69
64
  const entries = Object.entries(properties);
70
65
  if (entries.length === 0) {
@@ -8903,15 +8903,20 @@ function parseWrites(json) {
8903
8903
  return emptyWrites();
8904
8904
  }
8905
8905
  }
8906
- function scriptError(payload, kind) {
8906
+ function scriptError(payload) {
8907
8907
  if (payload === void 0) {
8908
- return { kind, message: "the script failed without an error message" };
8908
+ return { kind: "script", message: "the script failed without an error message" };
8909
8909
  }
8910
8910
  try {
8911
8911
  const parsed = JSON.parse(payload);
8912
- return { kind, name: parsed.name, message: parsed.message ?? payload, stack: parsed.stack };
8912
+ return {
8913
+ kind: "script",
8914
+ name: parsed.name,
8915
+ message: parsed.message ?? payload,
8916
+ stack: parsed.stack
8917
+ };
8913
8918
  } catch {
8914
- return { kind, message: payload };
8919
+ return { kind: "script", message: payload };
8915
8920
  }
8916
8921
  }
8917
8922
  async function runScript(wasm, start) {
@@ -8966,7 +8971,7 @@ async function runScript(wasm, start) {
8966
8971
  post({
8967
8972
  t: "done",
8968
8973
  ok: false,
8969
- error: scriptError(b === void 0 ? void 0 : b.toString(), "script"),
8974
+ error: scriptError(b === void 0 ? void 0 : b.toString()),
8970
8975
  writes
8971
8976
  });
8972
8977
  }
@@ -9034,8 +9039,7 @@ async function runScript(wasm, start) {
9034
9039
  t: "done",
9035
9040
  ok: false,
9036
9041
  error: scriptError(
9037
- JSON.stringify({ name: error.name, message: error.message, stack: error.stack }),
9038
- "script"
9042
+ JSON.stringify({ name: error.name, message: error.message, stack: error.stack })
9039
9043
  ),
9040
9044
  writes: emptyWrites()
9041
9045
  });
@@ -69,15 +69,20 @@ function parseWrites(json: string): StoreWrites {
69
69
  }
70
70
 
71
71
  /** 把 prelude 的 error 描述 JSON 转成可上报的错误。 */
72
- function scriptError(payload: string | undefined, kind: ScriptError["kind"]): ScriptError {
72
+ function scriptError(payload: string | undefined): ScriptError {
73
73
  if (payload === undefined) {
74
- return { kind, message: "the script failed without an error message" };
74
+ return { kind: "script", message: "the script failed without an error message" };
75
75
  }
76
76
  try {
77
77
  const parsed = JSON.parse(payload) as { name?: string; message?: string; stack?: string };
78
- return { kind, name: parsed.name, message: parsed.message ?? payload, stack: parsed.stack };
78
+ return {
79
+ kind: "script",
80
+ name: parsed.name,
81
+ message: parsed.message ?? payload,
82
+ stack: parsed.stack,
83
+ };
79
84
  } catch {
80
- return { kind, message: payload };
85
+ return { kind: "script", message: payload };
81
86
  }
82
87
  }
83
88
 
@@ -150,7 +155,7 @@ async function runScript(wasm: object, start: Extract<HostMessage, { t: "start"
150
155
  post({
151
156
  t: "done",
152
157
  ok: false,
153
- error: scriptError(b === undefined ? undefined : b.toString(), "script"),
158
+ error: scriptError(b === undefined ? undefined : b.toString()),
154
159
  writes,
155
160
  });
156
161
  }
@@ -229,7 +234,6 @@ async function runScript(wasm: object, start: Extract<HostMessage, { t: "start"
229
234
  ok: false,
230
235
  error: scriptError(
231
236
  JSON.stringify({ name: error.name, message: error.message, stack: error.stack }),
232
- "script",
233
237
  ),
234
238
  writes: emptyWrites(),
235
239
  });
package/src/gh/base.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  type ActionJob,
19
19
  type CheckRun,
20
20
  type CommitStatus,
21
+ createGithubApi,
21
22
  createGithubChecks,
22
23
  createGithubSearch,
23
24
  type GithubChecksClient,
@@ -31,7 +32,7 @@ import { createGithubReads, type GithubReads } from "../lib/github-reads.js";
31
32
  import { parseWithSchema } from "../lib/parse-with-schema.js";
32
33
  import { type ToolPendant } from "../lib/pendant.js";
33
34
  import { type StructuredResult } from "../lib/tool-bus.js";
34
- import type { checksVerdictSchema } from "./schemas.js";
35
+ import { type checksVerdictSchema, ghRunSummarySchema } from "./schemas.js";
35
36
 
36
37
  /** A tool result: what the model sees plus the structured details payload. */
37
38
  export interface ToolResult {
@@ -178,6 +179,29 @@ export async function resolveRepo(
178
179
  return nameWithOwner;
179
180
  }
180
181
 
182
+ /** 工具操作的仓库:`OWNER/REPO` 全名加拆分后的两段。 */
183
+ export interface RepoTarget {
184
+ /** 规范化的 `OWNER/REPO`(参数缺省时来自当前目录解析)。 */
185
+ readonly fullName: string;
186
+ readonly owner: string;
187
+ readonly name: string;
188
+ }
189
+
190
+ /**
191
+ * 解析工具该操作的仓库并拆开 `OWNER/REPO`。取数工具都要走这一步,收成一个调用
192
+ * 后每个工具只剩一行,也不再需要各自 import `splitRepo`。
193
+ */
194
+ export async function resolveRepoTarget(
195
+ repo: string | undefined,
196
+ signal: AbortSignal | undefined,
197
+ cwd: string | undefined,
198
+ input?: unknown,
199
+ ): Promise<RepoTarget> {
200
+ const fullName = await resolveRepo(repo, signal, cwd, input);
201
+ const { owner, repo: name } = splitRepo(fullName);
202
+ return { fullName, owner, name };
203
+ }
204
+
181
205
  export function truncate(
182
206
  text: string,
183
207
  maxLines = 2000,
@@ -294,6 +318,58 @@ export function subtitlePendant<IdKey extends string = never>(
294
318
  return { subtitle: parts.join(" ") };
295
319
  }
296
320
 
321
+ /**
322
+ * 给结果挂上 pendant subtitle 后原样返回:取数工具的固定收尾,省掉每个工具重复的
323
+ * `result.details.pendant = ...; return result;` 两行。
324
+ */
325
+ export function withPendant<T extends ToolResult, IdKey extends string = never>(
326
+ result: T,
327
+ params: { repo?: string } & Partial<Record<IdKey, string | number>>,
328
+ idKey?: IdKey,
329
+ ): T {
330
+ result.details.pendant = subtitlePendant(params, idKey);
331
+ return result;
332
+ }
333
+
334
+ /**
335
+ * `list-github-issues` / `list-github-prs` 共用的一套过滤参数,只有 `state` 与 `fields`
336
+ * 的说明文案按 kind 不同。
337
+ */
338
+ export function listFilterParameters(kind: "issue" | "pr") {
339
+ const isPr = kind === "pr";
340
+ return Type.Object({
341
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
342
+ keywords: Type.Optional(Type.String({ description: "Search keywords (free text)" })),
343
+ state: Type.Optional(
344
+ Type.String({
345
+ description: isPr
346
+ ? "open, closed, merged, all (default: open; all applies to keyword search and covers open + closed + merged)"
347
+ : "open, closed, all (default: open; all applies to keyword search and covers closed too)",
348
+ }),
349
+ ),
350
+ label: Type.Optional(Type.String({ description: "Filter by label" })),
351
+ // `@me` 只在关键词搜索分支可用:搜索 API 自己把它解析成当前登录用户。浏览分支走
352
+ // REST 列表端点,`@me` 按字面量转发,不会匹配到当前用户(本仓库不做展开)。
353
+ author: Type.Optional(
354
+ Type.String({
355
+ description: "Filter by author ('@me' works with keywords; browse takes it literally)",
356
+ }),
357
+ ),
358
+ assignee: Type.Optional(
359
+ Type.String({
360
+ description: "Filter by assignee ('@me' works with keywords; browse takes it literally)",
361
+ }),
362
+ ),
363
+ milestone: Type.Optional(Type.String({ description: "Filter by milestone" })),
364
+ limit: Type.Optional(Type.Number({ description: "Max results (default 30, max 100)" })),
365
+ fields: Type.Optional(
366
+ Type.String({
367
+ description: `Comma-separated columns for the rows (default: number,state,title,labels,updatedAt; adds repo when no repo given). Valid: number,state,title,url,author,labels,milestone,assignees,comments,repo,createdAt,updatedAt,closedAt${isPr ? ",mergedAt" : ""}`,
368
+ }),
369
+ ),
370
+ });
371
+ }
372
+
297
373
  export interface ListFilters {
298
374
  repo?: string;
299
375
  keywords?: string;
@@ -317,8 +393,11 @@ export async function browseList(
317
393
  params: ListFilters,
318
394
  ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
319
395
  ): Promise<{ repo: string; hits: SearchHit[] }> {
320
- const repo = params.repo ?? (await resolveRepo(undefined, ctx.signal, ctx.cwd, ctx.input));
321
- const { owner, repo: repoName } = splitRepo(repo);
396
+ const {
397
+ fullName: repo,
398
+ owner,
399
+ name,
400
+ } = await resolveRepoTarget(params.repo, ctx.signal, ctx.cwd, ctx.input);
322
401
  const query = {
323
402
  state: params.state,
324
403
  label: params.label,
@@ -329,8 +408,8 @@ export async function browseList(
329
408
  };
330
409
  const items =
331
410
  kind === "issue"
332
- ? await gh.reads.listIssues(owner, repoName, query, ctx.signal)
333
- : await gh.reads.listPulls(owner, repoName, query, ctx.signal);
411
+ ? await gh.reads.listIssues(owner, name, query, ctx.signal)
412
+ : await gh.reads.listPulls(owner, name, query, ctx.signal);
334
413
  return { repo, hits: normalizeRestList(items, repo) };
335
414
  }
336
415
 
@@ -367,9 +446,12 @@ export class GhClient {
367
446
  options: Pick<GithubClientOptions, "token"> = {},
368
447
  ) {
369
448
  this.fetch = fetchImpl;
370
- this.search = createGithubSearch({ fetch: fetchImpl, ...options });
371
- this.checks = createGithubChecks({ fetch: fetchImpl, ...options });
372
- this.reads = createGithubReads({ fetch: fetchImpl, ...options });
449
+ // 三个子客户端共用同一个 accessor,因此也共用同一份 octokit 与 token 缓存
450
+ // (各自 createGithubApi 会让一个会话最多 spawn 三次 `gh auth token`)。
451
+ const api = createGithubApi({ fetch: fetchImpl, ...options });
452
+ this.search = createGithubSearch(api);
453
+ this.checks = createGithubChecks(api);
454
+ this.reads = createGithubReads(api);
373
455
  }
374
456
  }
375
457
 
@@ -380,20 +462,6 @@ const CHECKS_POLL_INTERVAL_MS = 30_000;
380
462
  const RUN_WATCH_INTERVAL_MS = 30_000;
381
463
  const RUN_WATCH_DEADLINE_MS = 600_000;
382
464
 
383
- /** 运行快照:只声明我们渲染与载荷用到的字段,其余由 REST 原样带过。 */
384
- const runSnapshotSchema = Type.Object({
385
- id: Type.Number(),
386
- name: Type.Optional(Type.Union([Type.String(), Type.Null()])),
387
- display_title: Type.Optional(Type.String()),
388
- status: Type.Optional(Type.Union([Type.String(), Type.Null()])),
389
- conclusion: Type.Optional(Type.Union([Type.String(), Type.Null()])),
390
- head_branch: Type.Optional(Type.Union([Type.String(), Type.Null()])),
391
- event: Type.Optional(Type.String()),
392
- created_at: Type.Optional(Type.String()),
393
- updated_at: Type.Optional(Type.String()),
394
- run_started_at: Type.Optional(Type.String()),
395
- html_url: Type.Optional(Type.String()),
396
- });
397
465
  const CHECKS_WATCH_DEADLINE_MS = 600_000;
398
466
 
399
467
  export type CheckBucket = "pass" | "skipped" | "fail" | "pending";
@@ -688,7 +756,7 @@ export async function watchRun(options: WatchRunOptions): Promise<RunWatchResult
688
756
  const watchStart = Date.now();
689
757
  for (;;) {
690
758
  signal.throwIfAborted();
691
- const run = parseWithSchema(runSnapshotSchema, await reads.run(owner, repo, runId, signal));
759
+ const run = parseWithSchema(ghRunSummarySchema, await reads.run(owner, repo, runId, signal));
692
760
  const elapsedMs = Date.now() - watchStart;
693
761
  onUpdate?.({
694
762
  content: [
package/src/gh/index.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * GitHub Read-Only Tools Extension
3
3
  *
4
- * Provides individual read-only tools for GitHub operations using the system's `gh` CLI.
4
+ * Provides individual read-only tools for GitHub operations. 取数走 octokit REST;
5
+ * 只有「当前仓库」解析(`gh repo view`)与 token 获取(`gh auth token`)用系统 `gh`。
5
6
  *
6
7
  * Layout:
7
- * - `base.ts` shared helpers (gh subprocess, result shaping, REST client, checks watch)
8
- * - `tools/<name>.ts` one tool per file, each exporting `add<Name>Tool(gh, pi)`
8
+ * - `base.ts` shared helpers (result shaping, GhClient, repo resolution, checks watch)
9
+ * - `schemas.ts` structured-output schemas
10
+ * - `render.ts` text rendering of REST responses
11
+ * - `tools/<name>.ts` one tool per file, each exporting `add<Name>Tool(gh, bus)`
9
12
  * - `index.ts` this file: availability checks + tool registration
10
13
  *
11
14
  * Tools:
@@ -28,10 +31,10 @@
28
31
  * - wait-github-commit-checks: Watch CI checks of a commit (no PR required)
29
32
  * - watch-github-run: Watch a workflow run
30
33
  *
31
- * Proxy (for the gh CLI and for the octokit-backed search/checks requests):
34
+ * Proxy (shared by the `gh` subprocess and the octokit requests):
32
35
  * ~/.pi/agent/proxy.json: { "proxy": "http://127.0.0.1:7890", "noProxy": "localhost" }
33
36
  * HTTPS_PROXY / HTTP_PROXY / ALL_PROXY and NO_PROXY are used instead for the
34
- * fields the config file leaves out. The config is read once per process.
37
+ * fields the config file leaves out; the config is read through `src/lib/egress.ts`.
35
38
  */
36
39
 
37
40
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -9,7 +9,7 @@ import { type ToolPendant } from "../../lib/pendant.js";
9
9
  import { defineStructuredTool, type ToolBus } from "../../lib/tool-bus.js";
10
10
  import {
11
11
  type GhClient,
12
- resolveRepo,
12
+ resolveRepoTarget,
13
13
  splitRepo,
14
14
  structuredFailure,
15
15
  type StructuredFailureResult,
@@ -114,8 +114,11 @@ export async function downloadReleaseAssets(
114
114
  );
115
115
  }
116
116
 
117
- const effectiveRepo = await resolveRepo(params.repo, signal, ctx.cwd, params);
118
- const { owner, repo: repoName } = splitRepo(effectiveRepo);
117
+ const {
118
+ fullName: effectiveRepo,
119
+ owner,
120
+ name: repoName,
121
+ } = await resolveRepoTarget(params.repo, signal, ctx.cwd, params);
119
122
  const view = Value.Parse(
120
123
  releaseViewSchema,
121
124
  await gh.reads.release(owner, repoName, params.tag, signal),
@@ -3,13 +3,12 @@ import { Type } from "typebox";
3
3
  import { defineStructuredTool, type ToolBus } from "../../lib/tool-bus.js";
4
4
  import {
5
5
  type GhClient,
6
- resolveRepo,
7
- splitRepo,
6
+ resolveRepoTarget,
8
7
  type StructuredResultOf,
9
- subtitlePendant,
10
8
  type ToolCall,
11
9
  toPositiveId,
12
10
  toStructuredJsonResult,
11
+ withPendant,
13
12
  } from "../base.js";
14
13
  import { workflowJobsSchema } from "../schemas.js";
15
14
 
@@ -25,8 +24,7 @@ async function workflowJobs(
25
24
  ): Promise<StructuredResultOf<typeof workflowJobsSchema>> {
26
25
  const { params, ctx, signal } = call;
27
26
  const runId = toPositiveId(params.run_id, "run_id");
28
- const effectiveRepo = await resolveRepo(params.repo, signal, ctx.cwd, params);
29
- const { owner, repo: name } = splitRepo(effectiveRepo);
27
+ const { owner, name } = await resolveRepoTarget(params.repo, signal, ctx.cwd, params);
30
28
 
31
29
  const jobs = await gh.checks.runJobs(owner, name, runId, signal);
32
30
  const result = toStructuredJsonResult(
@@ -34,8 +32,7 @@ async function workflowJobs(
34
32
  params,
35
33
  workflowJobsSchema,
36
34
  );
37
- result.details.pendant = subtitlePendant(params, "run_id");
38
- return result;
35
+ return withPendant(result, params, "run_id");
39
36
  }
40
37
 
41
38
  export function addGetWorkflowJobsTool(gh: GhClient, bus: ToolBus) {