@trim21/personal-pi-extensions 0.1.514 → 0.1.517

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.514",
3
+ "version": "0.1.517",
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/aft/bridge.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * AFT bridge 管理:二进制解析、transport pool 生命周期、工具调用封装。
3
3
  *
4
- * 依赖 @cortexkit/aft-bridge(官方协议的 JS 客户端):findBinary 的解析顺序是
5
- * 缓存 → npm 平台包(@cortexkit/aft-<platform>,随 npm 镜像分发,无运行时网络)
6
- * → PATH → cargo → GitHub release 兜底;内网部署只要保证平台包版本与
4
+ * 依赖 @cortexkit/aft-bridge(官方协议的 JS 客户端):二进制解析由入口在
5
+ * session_start 用 findBinary 完成(缓存 → npm 平台包 PATH → cargo →
6
+ * GitHub release 兜底),本模块只接收解析结果;内网部署只要保证平台包版本与
7
7
  * aft-bridge 锁一致就不会走到最后的网络下载。
8
8
  */
9
9
 
@@ -12,7 +12,6 @@ import {
12
12
  type AftTransportPool,
13
13
  type BridgeRequestOptions,
14
14
  createAftTransportPool,
15
- findBinary,
16
15
  inlineUserConfigTier,
17
16
  readConfigTiers,
18
17
  resolveCortexKitConfigPaths,
@@ -25,6 +24,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
25
24
  import type { SemanticRemote } from "./config.js";
26
25
  import { type AftLogger, createAftLogger } from "./logger.js";
27
26
 
27
+ export { findBinary } from "@cortexkit/aft-bridge";
28
+
28
29
  /** Pi 会话 ID:Rust 侧用它做 session 作用域(undo/checkpoint),感知工具可留空。 */
29
30
  export function resolveSessionId(extCtx: ExtensionContext): string | undefined {
30
31
  const manager = (extCtx as unknown as { sessionManager?: { getSessionId?: () => string } })
@@ -33,22 +34,6 @@ export function resolveSessionId(extCtx: ExtensionContext): string | undefined {
33
34
  return typeof id === "string" && id.length > 0 ? id : undefined;
34
35
  }
35
36
 
36
- /**
37
- * 解析 aft 二进制;失败时抛出(调用方决定是否降级不注册工具)。
38
- * 不带版本参数:findBinary 内部用 @cortexkit/aft-bridge 自身版本作为匹配基准,
39
- * 与 npm 平台包(@cortexkit/aft-<platform>)精确对齐,避免手动读 package.json
40
- * (其 exports 不暴露 ./package.json)。
41
- */
42
- export async function resolveAftBinary(): Promise<string> {
43
- const path = await findBinary();
44
- if (!path) {
45
- throw new Error(
46
- "AFT binary not found. Install via npm platform package (@cortexkit/aft-<platform>), cargo install agent-file-tools, or place `aft` on PATH.",
47
- );
48
- }
49
- return path;
50
- }
51
-
52
37
  export interface AftPool {
53
38
  pool: AftTransportPool;
54
39
  /** 当前项目根(process.cwd),供 bridge 查询。 */
@@ -65,10 +50,11 @@ export interface AftState {
65
50
  export async function createAftState(
66
51
  cwd: string,
67
52
  sessionId: string | undefined,
53
+ binaryPath: string,
68
54
  semantic?: SemanticRemote,
69
55
  ): Promise<AftState> {
70
56
  const logger = createAftLogger(sessionId);
71
- const pool = await createAftPool(cwd, logger, semantic);
57
+ const pool = await createAftPool(cwd, logger, binaryPath, semantic);
72
58
  return { logger, pool };
73
59
  }
74
60
 
@@ -91,6 +77,7 @@ export const SEMANTIC_API_KEY_ENV = "AFT_SEMANTIC_API_KEY";
91
77
 
92
78
  /**
93
79
  * 创建 transport pool。每个项目根一个常驻 aft 进程,跨 session 共享。
80
+ * `binaryPath` 由入口在 session_start 解析好传入(含 auto-download 兜底)。
94
81
  *
95
82
  * `semantic` 决定 embedding 密钥如何送达子进程:aft 只从配置里读 `api_key_env`
96
83
  * 这个「变量名」,再自己 `env::var` 取值,所以提供值时必须连带把名字告诉它。
@@ -99,12 +86,12 @@ export const SEMANTIC_API_KEY_ENV = "AFT_SEMANTIC_API_KEY";
99
86
  export async function createAftPool(
100
87
  cwd: string,
101
88
  logger: AftLogger,
89
+ binaryPath: string,
102
90
  semantic?: SemanticRemote,
103
91
  ): Promise<AftPool> {
104
92
  // 必须在任何 bridge 代码运行前注册:不设 logger 时 aft-bridge 会把 child
105
93
  // stderr / 生命周期日志 fallback 到 console.error,raw 输出打进 pi 的 stderr 破坏 TUI。
106
94
  setActiveLogger(logger);
107
- const binaryPath = await resolveAftBinary();
108
95
  const paths = resolveCortexKitConfigPaths(cwd);
109
96
  const childEnv: Record<string, string> = {
110
97
  // Rust 侧 semantic_search 在索引 Building 时阻塞等待构建完成
package/src/aft/index.ts CHANGED
@@ -9,9 +9,11 @@
9
9
  * fastembed 后端不使用。
10
10
  *
11
11
  * bridge 状态(日志 + 常驻 aft 子进程)的生命周期跟 session 走:session_start
12
- * 时用当次 session id 创建(日志落在 tmp/{sessionId}/aft-plugin.log),
13
- * session_shutdown / 进程退出时释放。工具实现经 getState() 取状态,
14
- * session 未初始化时抛错。
12
+ * 时先解析 aft 二进制(含 GitHub release auto-download 兜底)——找不到就
13
+ * notify warning 且不注册任何 aft 工具,避免模型看到只会抛 "not initialized"
14
+ * 的死工具;找到则注册工具并创建 bridge 状态(日志落在
15
+ * tmp/{sessionId}/aft-plugin.log),session_shutdown / 进程退出时释放。
16
+ * 工具实现经 getState() 取状态。
15
17
  *
16
18
  * Usage:
17
19
  * pi -e ./aft/index.ts
@@ -20,7 +22,7 @@
20
22
  import { resolveCortexKitConfigPaths } from "@cortexkit/aft-bridge";
21
23
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
24
 
23
- import { createAftState, resolveSessionId, shutdownAftPool } from "./bridge.js";
25
+ import { createAftState, findBinary, resolveSessionId, shutdownAftPool } from "./bridge.js";
24
26
  import { loadAftConfig } from "./config.js";
25
27
  import {
26
28
  registerCallgraphTool,
@@ -37,12 +39,6 @@ export default function aftReadTools(pi: ExtensionAPI): void {
37
39
  // bridge 状态跟 session 生命周期走,作用域就是本工厂闭包,不落到模块级。
38
40
  let state: Awaited<ReturnType<typeof createAftState>> | null = null;
39
41
 
40
- // 预热:提前解析二进制并拉起 bridge 子进程;失败直接抛给 pi(runner 捕获
41
- // 后上报 ExtensionError),工具调用侧经 getState() 抛未初始化错误。
42
- pi.on("session_start", async (_event, ctx) => {
43
- state = await createAftState(cwd, resolveSessionId(ctx), cfg.semanticRemote);
44
- });
45
-
46
42
  const getState = (): Awaited<ReturnType<typeof createAftState>> => {
47
43
  if (!state) {
48
44
  throw new Error(
@@ -53,22 +49,38 @@ export default function aftReadTools(pi: ExtensionAPI): void {
53
49
  };
54
50
 
55
51
  const toolCtx = { cwd, getState };
56
- registerOutlineTool(pi, toolCtx);
57
- registerZoomTool(pi, toolCtx);
58
- registerCallgraphTool(pi, toolCtx);
59
- if (cfg.semanticSearch) {
60
- if (cfg.semanticRemote) {
61
- registerSearchTool(pi, toolCtx);
62
- } else {
63
- // 只开了开关、没配外部 embedding 后端:与其静默不注册,不如说明缺什么。
64
- pi.on("session_start", (_event, ctx) => {
52
+
53
+ // 工具注册延迟到 session_start:先确认二进制可用再决定注册面。pi 允许在
54
+ // session_start 里 registerTool(工具表按 name 覆盖,跨 session 重复注册幂等)。
55
+ pi.on("session_start", async (_event, ctx) => {
56
+ const binaryPath = await findBinary();
57
+ if (!binaryPath) {
58
+ ctx.ui.notify(
59
+ "AFT binary not found: aft_outline / aft_zoom / aft_callgraph are not registered. " +
60
+ "Install the npm platform package (@cortexkit/aft-<platform>), run `cargo install agent-file-tools`, " +
61
+ "or place `aft` on PATH, then restart pi.",
62
+ "warning",
63
+ );
64
+ return;
65
+ }
66
+
67
+ registerOutlineTool(pi, toolCtx);
68
+ registerZoomTool(pi, toolCtx);
69
+ registerCallgraphTool(pi, toolCtx);
70
+ if (cfg.semanticSearch) {
71
+ if (cfg.semanticRemote) {
72
+ registerSearchTool(pi, toolCtx);
73
+ } else {
74
+ // 只开了开关、没配外部 embedding 后端:与其静默不注册,不如说明缺什么。
65
75
  ctx.ui.notify(
66
76
  "aft_search is not registered: semantic_search needs an external embedding backend (aft.jsonc semantic.backend = openai_compatible | ollama, plus base_url). The local ONNX fastembed default is not used here.",
67
77
  "warning",
68
78
  );
69
- });
79
+ }
70
80
  }
71
- }
81
+
82
+ state = await createAftState(cwd, resolveSessionId(ctx), binaryPath, cfg.semanticRemote);
83
+ });
72
84
 
73
85
  // 释放当前 session 的 bridge 状态。session_shutdown 是 pi 的正常生命周期;
74
86
  // beforeExit 兜底进程自然退出(不能注册 SIGINT/SIGTERM——那会吞掉 pi 主进程
@@ -35,7 +35,7 @@ import { homedir } from "node:os";
35
35
  import { delimiter, dirname, join, resolve } from "node:path";
36
36
 
37
37
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
38
- import { Type } from "typebox";
38
+ import { type Static, Type } from "typebox";
39
39
  import { Value } from "typebox/value";
40
40
 
41
41
  import { createGithubSearch, type GithubSearch, renderHits } from "./lib/github.js";
@@ -266,6 +266,23 @@ const workflowRunSchema = Type.Object({
266
266
 
267
267
  const workflowRunsSchema = Type.Object({ workflow_runs: Type.Array(workflowRunSchema) });
268
268
 
269
+ /**
270
+ * One entry of `gh pr checks --json`. In JSON mode gh exits 0 once it could
271
+ * fetch the checks (real errors still exit non-zero); pass/fail/pending is
272
+ * only conveyed by the `bucket` field, never by the exit code.
273
+ */
274
+ const prCheckSchema = Type.Object({
275
+ name: Type.String(),
276
+ state: Type.String(),
277
+ bucket: Type.String(),
278
+ startedAt: Type.Union([Type.String(), Type.Null()]),
279
+ completedAt: Type.Union([Type.String(), Type.Null()]),
280
+ link: Type.Union([Type.String(), Type.Null()]),
281
+ workflow: Type.Union([Type.String(), Type.Null()]),
282
+ });
283
+
284
+ type PrCheck = Static<typeof prCheckSchema>;
285
+
269
286
  function truncate(
270
287
  text: string,
271
288
  maxLines = 2000,
@@ -1111,6 +1128,174 @@ export async function writeLogFile(
1111
1128
  };
1112
1129
  }
1113
1130
 
1131
+ // ── pr checks watch (pure rendering + poll loop) ────────────────────────────
1132
+
1133
+ const PR_CHECKS_JSON_FIELDS = "name,state,bucket,startedAt,completedAt,link,workflow";
1134
+ const CHECKS_POLL_INTERVAL_MS = 30_000;
1135
+ const CHECKS_WATCH_DEADLINE_MS = 600_000;
1136
+
1137
+ function bucketIcon(bucket: string): string {
1138
+ switch (bucket) {
1139
+ case "pass": {
1140
+ return "✅";
1141
+ }
1142
+ case "fail": {
1143
+ return "❌";
1144
+ }
1145
+ case "skipping": {
1146
+ return "⏭️";
1147
+ }
1148
+ case "cancel": {
1149
+ return "🚫";
1150
+ }
1151
+ default: {
1152
+ return "🔄";
1153
+ }
1154
+ }
1155
+ }
1156
+
1157
+ function formatClock(ms: number): string {
1158
+ const date = new Date(ms);
1159
+ return [date.getHours(), date.getMinutes(), date.getSeconds()]
1160
+ .map((n) => String(n).padStart(2, "0"))
1161
+ .join(":");
1162
+ }
1163
+
1164
+ function pad2(n: number): string {
1165
+ return String(n).padStart(2, "0");
1166
+ }
1167
+
1168
+ function formatDuration(totalSeconds: number): string {
1169
+ const seconds = Math.floor(totalSeconds);
1170
+ const h = Math.floor(seconds / 3600);
1171
+ const m = Math.floor((seconds % 3600) / 60);
1172
+ const s = seconds % 60;
1173
+ if (h > 0) return `${h}h${pad2(m)}m${pad2(s)}s`;
1174
+ if (m > 0) return `${m}m${pad2(s)}s`;
1175
+ return `${s}s`;
1176
+ }
1177
+
1178
+ /**
1179
+ * Render one polling round of `gh pr checks` like the GitHub web UI: check
1180
+ * name, workflow, status, start time and elapsed (total duration when the
1181
+ * check completed, time-so-far while still pending). Pure — no network.
1182
+ */
1183
+ export function renderPrChecksTable(options: {
1184
+ prNumber: number | string;
1185
+ round: number;
1186
+ checks: readonly PrCheck[];
1187
+ now: number;
1188
+ }): string {
1189
+ const { prNumber, round, checks, now } = options;
1190
+ const completed = checks.filter((c) => c.bucket !== "pending").length;
1191
+
1192
+ const rows = checks.map((check) => {
1193
+ const name = check.link ? `[${check.name}](${check.link})` : check.name;
1194
+ let elapsed = "—";
1195
+ if (check.startedAt) {
1196
+ const startMs = Date.parse(check.startedAt);
1197
+ const endMs = check.completedAt ? Date.parse(check.completedAt) : now;
1198
+ elapsed = formatDuration(Math.max(0, (endMs - startMs) / 1000));
1199
+ }
1200
+ const cells = [
1201
+ `${bucketIcon(check.bucket)} ${name}`,
1202
+ check.workflow ?? "—",
1203
+ check.bucket,
1204
+ check.startedAt ? formatClock(Date.parse(check.startedAt)) : "—",
1205
+ elapsed,
1206
+ ];
1207
+ return `| ${cells.join(" | ")} |`;
1208
+ });
1209
+ const body = rows.length > 0 ? rows.join("\n") : "| _no checks reported_ | — | — | — | — |";
1210
+
1211
+ return (
1212
+ `### PR #${prNumber} checks — round ${round}: ${completed}/${checks.length} complete\n\n` +
1213
+ `| Check | Workflow | Status | Started | Elapsed |\n` +
1214
+ `|---|---|---|---|---|\n` +
1215
+ body
1216
+ );
1217
+ }
1218
+
1219
+ function sleepInterruptibly(ms: number, signal: AbortSignal | undefined): Promise<void> {
1220
+ return new Promise((resolve, reject) => {
1221
+ const onAbort = () => {
1222
+ clearTimeout(timer);
1223
+ reject(new Error("aborted while waiting for the next checks poll"));
1224
+ };
1225
+ const timer = setTimeout(() => {
1226
+ signal?.removeEventListener("abort", onAbort);
1227
+ resolve();
1228
+ }, ms);
1229
+ if (signal?.aborted) {
1230
+ onAbort();
1231
+ return;
1232
+ }
1233
+ signal?.addEventListener("abort", onAbort, { once: true });
1234
+ });
1235
+ }
1236
+
1237
+ export interface PollPrChecksOptions {
1238
+ prNumber: number | string;
1239
+ repo?: string;
1240
+ failFast: boolean;
1241
+ cwd?: string;
1242
+ signal?: AbortSignal;
1243
+ /** Test overrides. */
1244
+ intervalMs?: number;
1245
+ deadlineMs?: number;
1246
+ onUpdate?: (msg: CiLogsResult) => void;
1247
+ }
1248
+
1249
+ /**
1250
+ * Poll `gh pr checks --json` until no check is pending (or a fail-fast
1251
+ * failure, or the deadline), emitting a GitHub-UI-like table via `onUpdate`
1252
+ * each round. In JSON mode gh exits 0 whenever it could fetch the checks —
1253
+ * completion is judged from the `bucket` field, not the exit code. A non-zero
1254
+ * exit (no checks reported, auth, network) is not fatal here: the caller
1255
+ * proceeds to the Actions API verification, which either produces the final
1256
+ * report or surfaces the error.
1257
+ */
1258
+ export async function pollPrChecks(options: PollPrChecksOptions): Promise<void> {
1259
+ const { prNumber, repo, failFast, cwd, signal, onUpdate } = options;
1260
+ const intervalMs = options.intervalMs ?? CHECKS_POLL_INTERVAL_MS;
1261
+ const deadlineMs = options.deadlineMs ?? CHECKS_WATCH_DEADLINE_MS;
1262
+ const args = [
1263
+ "pr",
1264
+ "checks",
1265
+ String(prNumber),
1266
+ ...repoArgs(repo),
1267
+ "--json",
1268
+ PR_CHECKS_JSON_FIELDS,
1269
+ ];
1270
+
1271
+ const watchStart = Date.now();
1272
+ for (let round = 1; ; round++) {
1273
+ const result = await runGh(args, { cwd, signal });
1274
+ if (result.killed) {
1275
+ throw new Error(
1276
+ result.reason === "timeout" ? "gh pr checks poll timed out" : "gh pr checks was aborted",
1277
+ );
1278
+ }
1279
+ if (result.code !== 0) {
1280
+ return;
1281
+ }
1282
+ const checks: PrCheck[] = Value.Parse(Type.Array(prCheckSchema), JSON.parse(result.stdout));
1283
+
1284
+ onUpdate?.({
1285
+ content: [
1286
+ { type: "text", text: renderPrChecksTable({ prNumber, round, checks, now: Date.now() }) },
1287
+ ],
1288
+ details: {},
1289
+ });
1290
+
1291
+ const hasPending = checks.some((c) => c.bucket === "pending");
1292
+ if (!hasPending) return;
1293
+ if (failFast && checks.some((c) => c.bucket === "fail")) return;
1294
+ if (Date.now() - watchStart >= deadlineMs) return;
1295
+ await sleepInterruptibly(intervalMs, signal);
1296
+ }
1297
+ }
1298
+
1114
1299
  // ── tools ────────────────────────────────────────────────────────────────────
1115
1300
 
1116
1301
  export default function ghReadonlyTools(pi: ExtensionAPI) {
@@ -1667,6 +1852,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1667
1852
  label: "Watch GitHub PR Checks",
1668
1853
  description:
1669
1854
  "Watch CI status checks for a PR until they complete. Blocks until all checks finish or one fails. " +
1855
+ "Each polling round streams a GitHub-UI-like check table (name, workflow, status, started, elapsed) via onUpdate. " +
1670
1856
  "Use this when you need to wait for CI to complete and see the final result.",
1671
1857
  promptSnippet: "Watch and wait for GitHub PR CI checks to complete",
1672
1858
  parameters: Type.Object({
@@ -1685,21 +1871,17 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1685
1871
  details: {},
1686
1872
  });
1687
1873
 
1688
- const args = ["pr", "checks", String(number), ...repoArgs(repo), "--watch"];
1689
- if (fail_fast) args.push("--fail-fast");
1690
-
1691
- // gh 的退出码语义不可靠:checks 失败时返回 exit 1(SilentError),挂起时
1692
- // 返回 exit 8(PendingError),且失败详情只在非结构化的 stdout 表格里。
1693
- // 因此 watch 退出后直接用 Actions API 抓取该 PR head 提交关联的所有
1874
+ // 轮询 `gh pr checks --json`(每轮经 onUpdate 流式输出 GitHub Web UI 风格
1875
+ // 的检查表),结束后再用 Actions API 抓取该 PR head 提交关联的所有
1694
1876
  // workflow job,以 job 的真实 conclusion 为准判断成功/失败。
1695
- const result = await runGh(args, { cwd: ctx.cwd, signal, timeout: 600_000 });
1696
- if (result.killed) {
1697
- throw new Error(
1698
- result.reason === "timeout"
1699
- ? "gh pr checks --watch timed out after 10 minutes"
1700
- : "gh pr checks --watch was aborted",
1701
- );
1702
- }
1877
+ await pollPrChecks({
1878
+ prNumber: number,
1879
+ repo,
1880
+ failFast: fail_fast === true,
1881
+ cwd: ctx.cwd,
1882
+ signal,
1883
+ onUpdate,
1884
+ });
1703
1885
 
1704
1886
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1705
1887
  const prOut = await ghExec(