@trim21/personal-pi-extensions 0.1.634 → 0.1.635

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.634",
3
+ "version": "0.1.635",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/gh/base.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  /**
2
- * Shared infrastructure of the GitHub read-only tools: the `gh` subprocess
3
- * layer, result shaping, the octokit-backed client, and the checks-wait
4
- * pipeline. Each tool lives in `tools/<tool-name>.ts`; anything used by more
5
- * than one tool belongs here.
2
+ * Shared infrastructure of the GitHub read-only tools: result shaping, the
3
+ * octokit-backed client, and the checks-wait pipeline (running a `gh` process
4
+ * itself lives in `lib/gh-process.ts`, which also owns the proxy env shared by
5
+ * the subprocess and octokit). Each tool lives in `tools/<tool-name>.ts`;
6
+ * anything used by more than one tool belongs here.
6
7
  */
7
8
 
8
- import { spawn } from "node:child_process";
9
9
  import { existsSync } from "node:fs";
10
10
  import { delimiter, join } from "node:path";
11
11
 
12
12
  import { Type } from "typebox";
13
13
  import { Value } from "typebox/value";
14
14
 
15
+ import { type GhResult, httpProxy, runGh } from "../lib/gh-process.js";
15
16
  import {
16
17
  type ActionJob,
17
18
  type CheckRun,
@@ -19,17 +20,11 @@ import {
19
20
  createGithubChecks,
20
21
  createGithubSearch,
21
22
  type GithubChecksClient,
23
+ type GithubClientOptions,
22
24
  type GithubSearch,
23
25
  renderHits,
24
26
  } from "../lib/github.js";
25
27
  import { type ToolPendant } from "../lib/pendant.js";
26
- import { createHttpProxy } from "../lib/proxy.js";
27
-
28
- /**
29
- * 代理配置(~/.pi/agent/proxy.json,回退到 HTTP(S)_PROXY 环境变量)在本模块内共享:
30
- * `gh` 子进程与 octokit 请求都从这里取,配置只在首次使用时读一次。
31
- */
32
- export const httpProxy = createHttpProxy();
33
28
 
34
29
  /** A tool result: what the model sees plus the structured details payload. */
35
30
  export interface ToolResult {
@@ -46,18 +41,6 @@ export interface ToolCall<Params> {
46
41
  onUpdate?: (update: ToolResult) => void;
47
42
  }
48
43
 
49
- export interface GhResult {
50
- stdout: string;
51
- stderr: string;
52
- code: number;
53
- killed: boolean;
54
- combined: string;
55
- /** Why the process was killed, when `killed` is true. */
56
- reason?: "timeout" | "abort";
57
- /** When the process could not be started at all (e.g. `gh` not found in PATH). */
58
- spawnError?: string;
59
- }
60
-
61
44
  /**
62
45
  * Check whether the `gh` CLI is on the system, scanning PATH like
63
46
  * `findDefaultBwrap`. The extension registers no tools when `gh` is missing, so
@@ -78,120 +61,6 @@ export function isGhAvailable(): boolean {
78
61
  return false;
79
62
  }
80
63
 
81
- export function runGh(
82
- args: string[],
83
- ctx: {
84
- cwd?: string;
85
- signal?: AbortSignal;
86
- timeout?: number;
87
- /** 追加到子进程环境变量(覆盖进程环境与代理配置),供测试或调用方定制。 */
88
- env?: NodeJS.ProcessEnv;
89
- },
90
- ): Promise<GhResult> {
91
- return new Promise((resolve) => {
92
- const proc = spawn("gh", args, {
93
- cwd: ctx.cwd,
94
- shell: false,
95
- stdio: ["ignore", "pipe", "pipe"],
96
- // gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
97
- env: { ...process.env, ...httpProxy.env, ...ctx.env, GH_PAGER: "cat" },
98
- });
99
-
100
- let stdout = "";
101
- let stderr = "";
102
- const combined: string[] = [];
103
- let killed = false;
104
- let killReason: "timeout" | "abort" | undefined;
105
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
106
- let onAbort: (() => void) | undefined;
107
-
108
- const killProcess = (reason: "timeout" | "abort") => {
109
- if (killed) {
110
- return;
111
- }
112
-
113
- killed = true;
114
- killReason = reason;
115
- proc.kill("SIGTERM");
116
- setTimeout(() => {
117
- if (!proc.killed) {
118
- proc.kill("SIGKILL");
119
- }
120
- }, 5000);
121
- };
122
-
123
- if (ctx.signal) {
124
- onAbort = () => killProcess("abort");
125
- if (ctx.signal.aborted) {
126
- killProcess("abort");
127
- } else {
128
- ctx.signal.addEventListener("abort", onAbort, { once: true });
129
- }
130
- }
131
-
132
- // Default timeout: 10 minutes. Long operations like downloading a CI job's
133
- // full log routinely take well over 30s, so a short default would kill them
134
- // mid-transfer; combined with `code ?? 0` that would silently cache a
135
- // truncated log as success. A killed process must never look successful.
136
- const timeout = ctx.timeout ?? 600_000;
137
- if (timeout > 0) {
138
- timeoutId = setTimeout(() => killProcess("timeout"), timeout);
139
- }
140
-
141
- proc.stdout.on("data", (data: Buffer) => {
142
- const text = data.toString();
143
- stdout += text;
144
- combined.push(text);
145
- });
146
- proc.stderr.on("data", (data: Buffer) => {
147
- const text = data.toString();
148
- stderr += text;
149
- combined.push(text);
150
- });
151
-
152
- proc.on("close", (code) => {
153
- if (timeoutId) {
154
- clearTimeout(timeoutId);
155
- }
156
- if (onAbort && ctx.signal) {
157
- ctx.signal.removeEventListener("abort", onAbort);
158
- }
159
- resolve({
160
- stdout,
161
- stderr,
162
- // When killed by a signal the close event's code is null — including
163
- // kills we did not initiate. Report failure instead of pretending it
164
- // succeeded. -1 is a sentinel for "did not exit normally" — distinct
165
- // from a real gh failure exit code (1), which is always in 0-255.
166
- code: code ?? -1,
167
- killed,
168
- combined: combined.join(""),
169
- reason: killReason,
170
- });
171
- });
172
-
173
- proc.on("error", (err: Error) => {
174
- if (timeoutId) {
175
- clearTimeout(timeoutId);
176
- }
177
- if (onAbort && ctx.signal) {
178
- ctx.signal.removeEventListener("abort", onAbort);
179
- }
180
- // spawn 失败(如 gh 不在 PATH → ENOENT、cwd 不存在)时进程从未启动,
181
- // 没有任何 stdout/stderr;把底层错误带上,否则会退化成无信息的 "exit code 1"。
182
- resolve({
183
- stdout,
184
- stderr,
185
- code: 1,
186
- killed,
187
- combined: combined.join(""),
188
- reason: killReason,
189
- spawnError: err.message,
190
- });
191
- });
192
- });
193
- }
194
-
195
64
  /**
196
65
  * Error thrown by `ghExec` when the `gh` invocation exits non-zero.
197
66
  * The message carries the toolcall input (JSON) wrapped in `<input>` markers,
@@ -474,10 +343,13 @@ export class GhClient {
474
343
  readonly search: GithubSearch;
475
344
  readonly checks: GithubChecksClient;
476
345
 
477
- constructor(fetchImpl: typeof globalThis.fetch = httpProxy.fetch) {
346
+ constructor(
347
+ fetchImpl: typeof globalThis.fetch = httpProxy.fetch,
348
+ options: Pick<GithubClientOptions, "token"> = {},
349
+ ) {
478
350
  this.fetch = fetchImpl;
479
- this.search = createGithubSearch({ fetch: fetchImpl });
480
- this.checks = createGithubChecks({ fetch: fetchImpl });
351
+ this.search = createGithubSearch({ fetch: fetchImpl, ...options });
352
+ this.checks = createGithubChecks({ fetch: fetchImpl, ...options });
481
353
  }
482
354
  }
483
355
 
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
 
4
- import { type GhClient, repoArgs, runGh, subtitlePendant } from "../base.js";
4
+ import { runGh } from "../../lib/gh-process.js";
5
+ import { type GhClient, repoArgs, subtitlePendant } from "../base.js";
5
6
 
6
7
  export function addWatchRunTool(_gh: GhClient, pi: ExtensionAPI) {
7
8
  pi.registerTool({
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `gh` subprocess adapter: start a `gh` process, collect its output, apply the
3
+ * shared timeout / kill semantics, and expose the proxy layer the subprocess
4
+ * inherits. It lives in `lib/` so both the gh tool layer (`src/gh/`) and the
5
+ * octokit clients (`lib/github.ts`) can depend on it without a cycle.
6
+ */
7
+
8
+ import { spawn } from "node:child_process";
9
+
10
+ import { createHttpProxy } from "./proxy.js";
11
+
12
+ /**
13
+ * 代理配置(~/.pi/agent/proxy.json,回退到 HTTP(S)_PROXY 环境变量)在本模块内共享:
14
+ * `gh` 子进程与 octokit 请求都从这里取,配置只在首次使用时读一次。
15
+ */
16
+ export const httpProxy = createHttpProxy();
17
+
18
+ export interface GhResult {
19
+ stdout: string;
20
+ stderr: string;
21
+ code: number;
22
+ killed: boolean;
23
+ combined: string;
24
+ /** Why the process was killed, when `killed` is true. */
25
+ reason?: "timeout" | "abort";
26
+ /** When the process could not be started at all (e.g. `gh` not found in PATH). */
27
+ spawnError?: string;
28
+ }
29
+
30
+ export interface GhRunContext {
31
+ cwd?: string;
32
+ signal?: AbortSignal;
33
+ timeout?: number;
34
+ /** 追加到子进程环境变量(覆盖进程环境与代理配置),供测试或调用方定制。 */
35
+ env?: NodeJS.ProcessEnv;
36
+ }
37
+
38
+ export function runGh(args: string[], ctx: GhRunContext): Promise<GhResult> {
39
+ return new Promise((resolve) => {
40
+ const proc = spawn("gh", args, {
41
+ cwd: ctx.cwd,
42
+ shell: false,
43
+ stdio: ["ignore", "pipe", "pipe"],
44
+ // gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
45
+ env: { ...process.env, ...httpProxy.env, ...ctx.env, GH_PAGER: "cat" },
46
+ });
47
+
48
+ let stdout = "";
49
+ let stderr = "";
50
+ const combined: string[] = [];
51
+ let killed = false;
52
+ let killReason: "timeout" | "abort" | undefined;
53
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
54
+ let onAbort: (() => void) | undefined;
55
+
56
+ const killProcess = (reason: "timeout" | "abort") => {
57
+ if (killed) {
58
+ return;
59
+ }
60
+
61
+ killed = true;
62
+ killReason = reason;
63
+ proc.kill("SIGTERM");
64
+ setTimeout(() => {
65
+ if (!proc.killed) {
66
+ proc.kill("SIGKILL");
67
+ }
68
+ }, 5000);
69
+ };
70
+
71
+ if (ctx.signal) {
72
+ onAbort = () => killProcess("abort");
73
+ if (ctx.signal.aborted) {
74
+ killProcess("abort");
75
+ } else {
76
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
77
+ }
78
+ }
79
+
80
+ // Default timeout: 10 minutes. Long operations like downloading a CI job's
81
+ // full log routinely take well over 30s, so a short default would kill them
82
+ // mid-transfer; combined with `code ?? 0` that would silently cache a
83
+ // truncated log as success. A killed process must never look successful.
84
+ const timeout = ctx.timeout ?? 600_000;
85
+ if (timeout > 0) {
86
+ timeoutId = setTimeout(() => killProcess("timeout"), timeout);
87
+ }
88
+
89
+ proc.stdout.on("data", (data: Buffer) => {
90
+ const text = data.toString();
91
+ stdout += text;
92
+ combined.push(text);
93
+ });
94
+ proc.stderr.on("data", (data: Buffer) => {
95
+ const text = data.toString();
96
+ stderr += text;
97
+ combined.push(text);
98
+ });
99
+
100
+ proc.on("close", (code) => {
101
+ if (timeoutId) {
102
+ clearTimeout(timeoutId);
103
+ }
104
+ if (onAbort && ctx.signal) {
105
+ ctx.signal.removeEventListener("abort", onAbort);
106
+ }
107
+ resolve({
108
+ stdout,
109
+ stderr,
110
+ // When killed by a signal the close event's code is null — including
111
+ // kills we did not initiate. Report failure instead of pretending it
112
+ // succeeded. -1 is a sentinel for "did not exit normally" — distinct
113
+ // from a real gh failure exit code (1), which is always in 0-255.
114
+ code: code ?? -1,
115
+ killed,
116
+ combined: combined.join(""),
117
+ reason: killReason,
118
+ });
119
+ });
120
+
121
+ proc.on("error", (err: Error) => {
122
+ if (timeoutId) {
123
+ clearTimeout(timeoutId);
124
+ }
125
+ if (onAbort && ctx.signal) {
126
+ ctx.signal.removeEventListener("abort", onAbort);
127
+ }
128
+ // spawn 失败(如 gh 不在 PATH → ENOENT、cwd 不存在)时进程从未启动,
129
+ // 没有任何 stdout/stderr;把底层错误带上,否则会退化成无信息的 "exit code 1"。
130
+ resolve({
131
+ stdout,
132
+ stderr,
133
+ code: 1,
134
+ killed,
135
+ combined: combined.join(""),
136
+ reason: killReason,
137
+ spawnError: err.message,
138
+ });
139
+ });
140
+ });
141
+ }
142
+
143
+ /** Read the logged-in token from the system `gh` CLI (`gh auth token`). */
144
+ export async function ghAuthToken(): Promise<string> {
145
+ const result = await runGh(["auth", "token"], { timeout: 10_000 });
146
+ if (result.spawnError) {
147
+ throw new Error(`failed to start gh: ${result.spawnError}`);
148
+ }
149
+ const token = result.stdout.trim();
150
+ if (token && result.code === 0) {
151
+ return token;
152
+ }
153
+ throw new Error(
154
+ result.stderr.trim() ||
155
+ `gh auth token exited with code ${result.code} — run "gh auth login" first`,
156
+ );
157
+ }
package/src/lib/github.ts CHANGED
@@ -8,11 +8,10 @@
8
8
  * rendered state is derived from `pull_request.merged_at`.
9
9
  */
10
10
 
11
- import { spawn } from "node:child_process";
12
-
13
11
  import { Octokit } from "octokit";
14
12
  import { type Static, Type } from "typebox";
15
13
 
14
+ import { ghAuthToken } from "./gh-process.js";
16
15
  import { parseWithSchema } from "./parse-with-schema.js";
17
16
 
18
17
  export type SearchKind = "issue" | "pr";
@@ -218,38 +217,6 @@ function normalize(raw: SearchItem): SearchHit {
218
217
  };
219
218
  }
220
219
 
221
- function ghAuthToken(): Promise<string> {
222
- return new Promise((resolve, reject) => {
223
- const proc = spawn("gh", ["auth", "token"], { stdio: ["ignore", "pipe", "pipe"] });
224
- let stdout = "";
225
- let stderr = "";
226
- const timer = setTimeout(() => proc.kill("SIGTERM"), 10_000);
227
- proc.stdout.on("data", (d: Buffer) => {
228
- stdout += String(d);
229
- });
230
- proc.stderr.on("data", (d: Buffer) => {
231
- stderr += String(d);
232
- });
233
- proc.on("error", (err) => {
234
- clearTimeout(timer);
235
- reject(new Error(`failed to start gh: ${err.message}`));
236
- });
237
- proc.on("close", (code) => {
238
- clearTimeout(timer);
239
- const token = stdout.trim();
240
- if (code === 0 && token) {
241
- resolve(token);
242
- } else {
243
- reject(
244
- new Error(
245
- stderr.trim() || `gh auth token exited with code ${code} — run "gh auth login" first`,
246
- ),
247
- );
248
- }
249
- });
250
- });
251
- }
252
-
253
220
  /**
254
221
  * Error thrown when the GitHub search API rejects the request. Carries the
255
222
  * original toolcall params so the model can see the exact input.
@@ -291,6 +258,8 @@ export interface GithubClientOptions {
291
258
  * proxy dispatcher attached. Defaults to the global fetch.
292
259
  */
293
260
  fetch?: typeof globalThis.fetch;
261
+ /** gh token provider;缺省读系统 `gh auth token`(见 lib/gh-process.ts)。 */
262
+ token?: () => Promise<string>;
294
263
  }
295
264
 
296
265
  /**
@@ -304,7 +273,7 @@ function createGithubApi(options: GithubClientOptions = {}): GithubApi {
304
273
 
305
274
  async function getClient(): Promise<Octokit> {
306
275
  client ??= new Octokit({
307
- auth: await ghAuthToken(),
276
+ auth: await (options.token ?? ghAuthToken)(),
308
277
  ...(options.fetch && { request: { fetch: options.fetch } }),
309
278
  });
310
279
  return client;