@trim21/personal-pi-extensions 0.1.685 → 0.1.688

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 CHANGED
@@ -16,7 +16,7 @@
16
16
  | [写保护(内置)](#写保护内置) | 写工具内置:限制文件写入在 workspace 内,外部写入需审批 |
17
17
  | [LSP(内置)](#lsp内置) | 文件工具内置 LSP 诊断 + `lsp-rename`/`lsp-inspect`/`lsp-find-definition`/`lsp-find-reference` |
18
18
  | [aft](#aft) | AFT 只读代码感知:`aft_outline`/`aft_zoom`/`aft_callgraph`/`aft_search` |
19
- | [gh-readonly](#gh-readonly) | GitHub 只读工具集(issue / PR / CI / release),基于 `gh` CLI |
19
+ | [gh-readonly](#gh-readonly) | GitHub 只读工具集(issue / PR / CI / release),数据走 octokit REST |
20
20
  | [spawn-agent](#spawn-agent) | 把任务委派给独立上下文窗口的子代理 |
21
21
  | [system-prompt](#system-prompt) | 完全替换 pi 默认 system prompt |
22
22
  | [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时提供 `describe_image`,调用视觉模型识别图片 |
@@ -461,7 +461,7 @@ pi -e ./src/aft/index.ts
461
461
 
462
462
  ## gh-readonly
463
463
 
464
- GitHub 只读工具集,基于系统 [`gh`](https://cli.github.com/) CLI(关键词搜索与 checks 查询走 octokit REST)。`gh` 不在 PATH 时整组不注册并在 session_start 报错;Windows 禁用。
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 禁用。
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`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.685",
3
+ "version": "0.1.688",
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
@@ -20,14 +20,14 @@ import {
20
20
  type CommitStatus,
21
21
  createGithubChecks,
22
22
  createGithubSearch,
23
- GH_LIST_FIELDS,
24
23
  type GithubChecksClient,
25
24
  type GithubClientOptions,
26
25
  type GithubSearch,
27
- normalizeGhList,
26
+ normalizeRestList,
28
27
  renderHits,
29
28
  type SearchHit,
30
29
  } from "../lib/github.js";
30
+ import { createGithubReads, type GithubReads } from "../lib/github-reads.js";
31
31
  import { parseWithSchema } from "../lib/parse-with-schema.js";
32
32
  import { type ToolPendant } from "../lib/pendant.js";
33
33
  import { type StructuredResult } from "../lib/tool-bus.js";
@@ -143,24 +143,6 @@ export async function ghExec(
143
143
  return result.stdout;
144
144
  }
145
145
 
146
- export function repoArgs(repo?: string): string[] {
147
- return repo ? ["--repo", repo] : [];
148
- }
149
-
150
- /**
151
- * `gh api` for a JSON-array endpoint, following pagination. The REST API pages
152
- * these lists at 30 items by default, so a single page silently drops the rest;
153
- * `--slurp` is required because `--paginate` alone prints the pages back to back
154
- * (not valid JSON), and the page arrays are flattened back into one list.
155
- */
156
- export async function ghApiList(
157
- path: string,
158
- ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
159
- ): Promise<unknown[]> {
160
- const out = await ghExec(["api", "--paginate", "--slurp", path], ctx);
161
- return Value.Parse(Type.Array(Type.Array(Type.Unknown())), JSON.parse(out)).flat();
162
- }
163
-
164
146
  /** Split `OWNER/REPO`; throws when the name doesn't have exactly one slash. */
165
147
  export function splitRepo(nameWithOwner: string): { owner: string; repo: string } {
166
148
  const slash = nameWithOwner.indexOf("/");
@@ -326,53 +308,30 @@ export interface ListFilters {
326
308
  }
327
309
 
328
310
  /**
329
- * Build the `gh` argv for browsing issues/PRs (no keyword search).
330
- *
331
- * Keyword searches no longer go through the `gh` CLI — the octokit-based client
332
- * in `../lib/github.ts` handles them with state values (`all`, and `merged` for
333
- * PRs) that `gh search` cannot express. Browse calls keep `gh issue list` /
334
- * `gh pr list` semantics: `state` is passed through verbatim, since `gh issue
335
- * list` accepts open/closed/all and `gh pr list` additionally accepts merged.
336
- */
337
- export function listGithubArgs(kind: "issue" | "pr", params: ListFilters): string[] {
338
- const { repo, state, label, author, assignee, milestone, limit } = params;
339
-
340
- const args = [kind, "list", ...repoArgs(repo)];
341
- if (state) {
342
- args.push("--state", state);
343
- }
344
- if (label) {
345
- args.push("--label", label);
346
- }
347
- if (author) {
348
- args.push("--author", author);
349
- }
350
- if (assignee) {
351
- args.push("--assignee", assignee);
352
- }
353
- if (milestone) {
354
- args.push("--milestone", milestone);
355
- }
356
- if (limit) {
357
- args.push("--limit", String(limit));
358
- }
359
- return args;
360
- }
361
-
362
- /**
363
- * 浏览分支(`gh issue list` / `gh pr list`,不带 keywords):直接要 `--json`,再归一到与
364
- * 搜索分支同一套 `SearchHit`。repo 缺省时先解析当前仓库——文本要据此决定是否带 repo 列,
365
- * 载荷里也必须有值。
311
+ * 浏览分支(不带 keywords):走 octokit 的 REST 列表端点,再归一到与搜索分支同一套
312
+ * `SearchHit`。repo 缺省时先解析当前仓库——文本要据此决定是否带 repo 列,载荷里也必须有值。
366
313
  */
367
314
  export async function browseList(
315
+ gh: GhClient,
368
316
  kind: "issue" | "pr",
369
317
  params: ListFilters,
370
318
  ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
371
319
  ): Promise<{ repo: string; hits: SearchHit[] }> {
372
320
  const repo = params.repo ?? (await resolveRepo(undefined, ctx.signal, ctx.cwd, ctx.input));
373
- const args = [...listGithubArgs(kind, params), "--json", GH_LIST_FIELDS];
374
- const stdout = await ghExec(args, ctx);
375
- return { repo, hits: normalizeGhList(stdout, repo) };
321
+ const { owner, repo: repoName } = splitRepo(repo);
322
+ const query = {
323
+ state: params.state,
324
+ label: params.label,
325
+ author: params.author,
326
+ assignee: params.assignee,
327
+ milestone: params.milestone,
328
+ limit: params.limit,
329
+ };
330
+ const items =
331
+ kind === "issue"
332
+ ? await gh.reads.listIssues(owner, repoName, query, ctx.signal)
333
+ : await gh.reads.listPulls(owner, repoName, query, ctx.signal);
334
+ return { repo, hits: normalizeRestList(items, repo) };
376
335
  }
377
336
 
378
337
  /** 行列表的文本:两条分支共用一套 TSV 渲染;空结果是成功结果。 */
@@ -400,6 +359,8 @@ export class GhClient {
400
359
  readonly fetch: typeof globalThis.fetch;
401
360
  readonly search: GithubSearch;
402
361
  readonly checks: GithubChecksClient;
362
+ /** REST 读取层:gh 工具的取数都走它(不再 spawn `gh` 取数据)。 */
363
+ readonly reads: GithubReads;
403
364
 
404
365
  constructor(
405
366
  fetchImpl: typeof globalThis.fetch = egress.fetch,
@@ -408,12 +369,31 @@ export class GhClient {
408
369
  this.fetch = fetchImpl;
409
370
  this.search = createGithubSearch({ fetch: fetchImpl, ...options });
410
371
  this.checks = createGithubChecks({ fetch: fetchImpl, ...options });
372
+ this.reads = createGithubReads({ fetch: fetchImpl, ...options });
411
373
  }
412
374
  }
413
375
 
414
376
  // ── checks watch (pure rendering + poll loop) ────────────────────────────────
415
377
 
416
378
  const CHECKS_POLL_INTERVAL_MS = 30_000;
379
+ /** `watch-github-run` 的轮询间隔与上限。 */
380
+ const RUN_WATCH_INTERVAL_MS = 30_000;
381
+ const RUN_WATCH_DEADLINE_MS = 600_000;
382
+
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
+ });
417
397
  const CHECKS_WATCH_DEADLINE_MS = 600_000;
418
398
 
419
399
  export type CheckBucket = "pass" | "skipped" | "fail" | "pending";
@@ -654,6 +634,81 @@ export async function pollPrChecks(options: PollPrChecksOptions): Promise<Checks
654
634
  }
655
635
  }
656
636
 
637
+ /** One polled workflow run: the REST fields the watch loop reports and renders. */
638
+ export interface RunSnapshot {
639
+ id: number;
640
+ name?: string | null;
641
+ display_title?: string;
642
+ status?: string | null;
643
+ conclusion?: string | null;
644
+ head_branch?: string | null;
645
+ event?: string;
646
+ created_at?: string;
647
+ updated_at?: string;
648
+ run_started_at?: string;
649
+ html_url?: string;
650
+ }
651
+
652
+ /** 一行运行状态:`工作流 · 事件 · 分支 — 状态`(附耗时与链接)。 */
653
+ export function renderRunStatus(run: RunSnapshot, elapsedMs?: number): string {
654
+ const state = run.conclusion ?? run.status ?? "unknown";
655
+ const facts = [run.name, run.event, run.head_branch].filter(Boolean).join(" · ");
656
+ const elapsed = elapsedMs === undefined ? "" : ` (${Math.round(elapsedMs / 1000)}s)`;
657
+ return `${facts} — ${state}${elapsed}${run.html_url === undefined ? "" : `\n${run.html_url}`}`;
658
+ }
659
+
660
+ export interface WatchRunOptions {
661
+ owner: string;
662
+ repo: string;
663
+ runId: number;
664
+ reads: GithubReads;
665
+ /** Owned by the caller; the poll loop observes it but never aborts it. */
666
+ signal: AbortSignal;
667
+ /** Test overrides. */
668
+ intervalMs?: number;
669
+ deadlineMs?: number;
670
+ onUpdate?: (msg: ToolResult) => void;
671
+ }
672
+
673
+ export interface RunWatchResult {
674
+ outcome: "completed" | "timeout";
675
+ run: RunSnapshot;
676
+ elapsedMs: number;
677
+ }
678
+
679
+ /**
680
+ * 轮询 `getWorkflowRun` 直到运行结束(`gh run watch` 的替代):固定间隔轮询,每轮通过
681
+ * onUpdate 报一次当前状态,超过上限以 timeout 结束(不挂住)。
682
+ */
683
+ export async function watchRun(options: WatchRunOptions): Promise<RunWatchResult> {
684
+ const { owner, repo, runId, reads, signal, onUpdate } = options;
685
+ const intervalMs = options.intervalMs ?? RUN_WATCH_INTERVAL_MS;
686
+ const deadlineMs = options.deadlineMs ?? RUN_WATCH_DEADLINE_MS;
687
+
688
+ const watchStart = Date.now();
689
+ for (;;) {
690
+ signal.throwIfAborted();
691
+ const run = parseWithSchema(runSnapshotSchema, await reads.run(owner, repo, runId, signal));
692
+ const elapsedMs = Date.now() - watchStart;
693
+ onUpdate?.({
694
+ content: [
695
+ {
696
+ type: "text",
697
+ text: `Watching workflow run ${runId}...\n${renderRunStatus(run, elapsedMs)}`,
698
+ },
699
+ ],
700
+ details: {},
701
+ });
702
+ if (run.status === "completed") {
703
+ return { outcome: "completed", run, elapsedMs };
704
+ }
705
+ if (elapsedMs >= deadlineMs) {
706
+ return { outcome: "timeout", run, elapsedMs };
707
+ }
708
+ await sleepInterruptibly(intervalMs, signal);
709
+ }
710
+ }
711
+
657
712
  /** Job conclusions that count as "did not succeed" for CI result reporting. */
658
713
  const FAILED_JOB_CONCLUSIONS = new Set([
659
714
  "failure",
package/src/gh/render.ts CHANGED
@@ -6,15 +6,20 @@
6
6
 
7
7
  import { dateOnly } from "../lib/github.js";
8
8
 
9
- /** 一行一个 release:`tag 标记 发布日期 标题`。 */
9
+ /**
10
+ * 一行一个 release:`tag 标记 发布日期 标题`。
11
+ *
12
+ * 标记有 `latest`(该仓库最新发布)/ `prerelease` / `draft`;REST 没有「isLatest」字段
13
+ * (那是 gh 按列表顺序算的),所以由调用方按第一个非 draft/prerelease 的条目传进来。
14
+ */
10
15
  export function renderReleaseList(
11
16
  releases: {
12
- tagName: string;
13
- name?: string;
14
- isLatest?: boolean;
15
- isPrerelease?: boolean;
16
- isDraft?: boolean;
17
- publishedAt?: string | null;
17
+ tag_name: string;
18
+ name?: string | null;
19
+ latest?: boolean;
20
+ prerelease?: boolean;
21
+ draft?: boolean;
22
+ published_at?: string | null;
18
23
  }[],
19
24
  ): string {
20
25
  if (releases.length === 0) {
@@ -23,14 +28,14 @@ export function renderReleaseList(
23
28
  return releases
24
29
  .map((release) => {
25
30
  const flags = [
26
- release.isLatest ? "latest" : "",
27
- release.isPrerelease ? "prerelease" : "",
28
- release.isDraft ? "draft" : "",
31
+ release.latest ? "latest" : "",
32
+ release.prerelease ? "prerelease" : "",
33
+ release.draft ? "draft" : "",
29
34
  ].filter(Boolean);
30
35
  return [
31
- release.tagName,
36
+ release.tag_name,
32
37
  flags.join(","),
33
- dateOnly(release.publishedAt),
38
+ dateOnly(release.published_at),
34
39
  release.name ?? "",
35
40
  ].join("\t");
36
41
  })
@@ -40,14 +45,14 @@ export function renderReleaseList(
40
45
  /** 一行一个运行:`id 状态 结论 工作流 分支 事件 创建时间 链接`。 */
41
46
  export function renderRunList(
42
47
  runs: {
43
- databaseId: number;
44
- status?: string;
48
+ id: number;
49
+ status?: string | null;
45
50
  conclusion?: string | null;
46
- workflowName?: string;
47
- headBranch?: string;
51
+ name?: string | null;
52
+ head_branch?: string | null;
48
53
  event?: string;
49
- createdAt?: string;
50
- url?: string;
54
+ created_at?: string;
55
+ html_url?: string;
51
56
  }[],
52
57
  ): string {
53
58
  if (runs.length === 0) {
@@ -56,14 +61,14 @@ export function renderRunList(
56
61
  return runs
57
62
  .map((run) =>
58
63
  [
59
- String(run.databaseId),
64
+ String(run.id),
60
65
  run.status ?? "",
61
66
  run.conclusion ?? "",
62
- run.workflowName ?? "",
63
- run.headBranch ?? "",
67
+ run.name ?? "",
68
+ run.head_branch ?? "",
64
69
  run.event ?? "",
65
- dateOnly(run.createdAt),
66
- run.url ?? "",
70
+ dateOnly(run.created_at),
71
+ run.html_url ?? "",
67
72
  ].join("\t"),
68
73
  )
69
74
  .join("\n");
@@ -71,37 +76,35 @@ export function renderRunList(
71
76
 
72
77
  /** 仓库概览:标题行 + 一行事实 + 一行链接 + 一行日期/许可。 */
73
78
  export function renderRepoView(repo: {
74
- nameWithOwner?: string;
75
- description?: string;
76
- url?: string;
79
+ full_name?: string;
80
+ description?: string | null;
81
+ html_url?: string;
77
82
  visibility?: string;
78
- primaryLanguage?: { name: string } | null;
79
- defaultBranchRef?: { name: string } | null;
80
- stargazerCount?: number;
81
- forkCount?: number;
82
- issues?: { totalCount: number } | null;
83
- pullRequests?: { totalCount: number } | null;
84
- licenseInfo?: { name: string } | null;
85
- pushedAt?: string;
86
- createdAt?: string;
83
+ language?: string | null;
84
+ default_branch?: string;
85
+ stargazers_count?: number;
86
+ forks_count?: number;
87
+ open_issues_count?: number;
88
+ license?: { name?: string } | null;
89
+ pushed_at?: string;
90
+ created_at?: string;
87
91
  }): string {
88
92
  const facts = [
89
93
  repo.visibility?.toLowerCase(),
90
- repo.primaryLanguage?.name,
91
- repo.defaultBranchRef && `default branch ${repo.defaultBranchRef.name}`,
92
- repo.stargazerCount !== undefined && `stars ${repo.stargazerCount}`,
93
- repo.forkCount !== undefined && `forks ${repo.forkCount}`,
94
- repo.issues && `open issues ${repo.issues.totalCount}`,
95
- repo.pullRequests && `open PRs ${repo.pullRequests.totalCount}`,
94
+ repo.language,
95
+ repo.default_branch && `default branch ${repo.default_branch}`,
96
+ repo.stargazers_count !== undefined && `stars ${repo.stargazers_count}`,
97
+ repo.forks_count !== undefined && `forks ${repo.forks_count}`,
98
+ repo.open_issues_count !== undefined && `open issues ${repo.open_issues_count}`,
96
99
  ].filter(Boolean);
97
100
  return [
98
- [repo.nameWithOwner, repo.description].filter(Boolean).join(" — "),
101
+ [repo.full_name, repo.description].filter(Boolean).join(" — "),
99
102
  facts.join(" · "),
100
- repo.url ?? "",
103
+ repo.html_url ?? "",
101
104
  [
102
- repo.pushedAt && `pushed ${dateOnly(repo.pushedAt)}`,
103
- repo.createdAt && `created ${dateOnly(repo.createdAt)}`,
104
- repo.licenseInfo?.name,
105
+ repo.pushed_at && `pushed ${dateOnly(repo.pushed_at)}`,
106
+ repo.created_at && `created ${dateOnly(repo.created_at)}`,
107
+ repo.license?.name,
105
108
  ]
106
109
  .filter(Boolean)
107
110
  .join(" · "),
@@ -112,33 +115,30 @@ export function renderRepoView(repo: {
112
115
 
113
116
  /** release 详情:标题、事实、资产清单、正文。 */
114
117
  export function renderReleaseView(release: {
115
- tagName?: string;
116
- name?: string;
117
- url?: string;
118
- isLatest?: boolean;
119
- isPrerelease?: boolean;
120
- isDraft?: boolean;
121
- publishedAt?: string | null;
122
- createdAt?: string;
118
+ tag_name?: string;
119
+ name?: string | null;
120
+ html_url?: string;
121
+ /** 由调用方按「列表里第一个非 draft / prerelease」推断(REST 没有这个字段)。 */
122
+ latest?: boolean;
123
+ prerelease?: boolean;
124
+ draft?: boolean;
125
+ published_at?: string | null;
126
+ created_at?: string;
123
127
  author?: { login?: string } | null;
124
- assets?: { name?: string; size?: number; downloadCount?: number }[];
125
- body?: string;
128
+ assets?: { name?: string; size?: number; download_count?: number }[];
129
+ body?: string | null;
126
130
  }): string {
127
- const flags = [
128
- release.isLatest && "latest",
129
- release.isPrerelease && "prerelease",
130
- release.isDraft && "draft",
131
- ]
131
+ const flags = [release.prerelease && "prerelease", release.draft && "draft"]
132
132
  .filter(Boolean)
133
133
  .join(",");
134
134
  const assets = release.assets ?? [];
135
135
  const lines = [
136
- [release.tagName, release.name].filter(Boolean).join(" — "),
136
+ [release.tag_name, release.name].filter(Boolean).join(" — "),
137
137
  [
138
- `published ${dateOnly(release.publishedAt ?? release.createdAt)}`,
138
+ `published ${dateOnly(release.published_at ?? release.created_at)}`,
139
139
  flags,
140
140
  release.author?.login && `by ${release.author.login}`,
141
- release.url,
141
+ release.html_url,
142
142
  ]
143
143
  .filter(Boolean)
144
144
  .join(" · "),
@@ -147,7 +147,7 @@ export function renderReleaseView(release: {
147
147
  lines.push(`assets (${assets.length}):`);
148
148
  for (const asset of assets) {
149
149
  lines.push(
150
- `- ${asset.name ?? ""} ${asset.size ?? 0} bytes, ${asset.downloadCount ?? 0} downloads`,
150
+ `- ${asset.name ?? ""} ${asset.size ?? 0} bytes, ${asset.download_count ?? 0} downloads`,
151
151
  );
152
152
  }
153
153
  }