@trim21/personal-pi-extensions 0.1.651 → 0.1.653

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 (52) hide show
  1. package/README.md +67 -9
  2. package/package.json +3 -11
  3. package/src/aft/index.ts +40 -23
  4. package/src/aft/tools.ts +10 -9
  5. package/src/claude-code/files.ts +99 -54
  6. package/src/claude-code/glob.ts +8 -5
  7. package/src/claude-code/grep.ts +8 -5
  8. package/src/claude-code/index.ts +56 -10
  9. package/src/claude-code/search.ts +4 -3
  10. package/src/claude-code/session-tools.ts +4 -3
  11. package/src/claude-code/shell.ts +3 -8
  12. package/src/gh/index.ts +48 -25
  13. package/src/gh/tools/download-release-assets.ts +3 -3
  14. package/src/gh/tools/get-workflow-jobs.ts +3 -3
  15. package/src/gh/tools/list-issues.ts +3 -3
  16. package/src/gh/tools/list-prs.ts +3 -3
  17. package/src/gh/tools/list-releases.ts +3 -3
  18. package/src/gh/tools/list-workflow-runs.ts +3 -3
  19. package/src/gh/tools/read-ci-logs.ts +3 -4
  20. package/src/gh/tools/read-issue-comments.ts +3 -3
  21. package/src/gh/tools/read-issue.ts +3 -3
  22. package/src/gh/tools/read-pr-comments.ts +3 -3
  23. package/src/gh/tools/read-pr-diff.ts +3 -3
  24. package/src/gh/tools/read-pr-status.ts +3 -3
  25. package/src/gh/tools/read-pr.ts +3 -3
  26. package/src/gh/tools/read-release.ts +3 -3
  27. package/src/gh/tools/read-repo.ts +3 -3
  28. package/src/gh/tools/wait-commit-checks.ts +3 -3
  29. package/src/gh/tools/wait-pr-checks.ts +3 -3
  30. package/src/gh/tools/watch-run.ts +3 -3
  31. package/src/index.ts +120 -0
  32. package/src/lib/lsp/inspect-tool.ts +5 -5
  33. package/src/lib/lsp/rename-tool.ts +4 -7
  34. package/src/lib/tool-bus.ts +126 -0
  35. package/src/lib/tool-registration.ts +83 -0
  36. package/src/lib/tool-services.ts +81 -0
  37. package/src/lib/tool-units.ts +102 -0
  38. package/src/lib/tools-config.ts +277 -0
  39. package/src/opencode/bash.ts +7 -2
  40. package/src/opencode/files.ts +90 -43
  41. package/src/opencode/glob.ts +7 -1
  42. package/src/opencode/grep.ts +7 -1
  43. package/src/opencode/index.ts +66 -21
  44. package/src/opencode/question.ts +8 -2
  45. package/src/opencode/todo.ts +8 -2
  46. package/src/orphan-worker-cleaner.ts +38 -0
  47. package/src/spawn-agent.ts +160 -175
  48. package/src/talk/index.ts +92 -66
  49. package/src/vision-agent.ts +31 -7
  50. package/src/web/fetch.ts +89 -72
  51. package/src/web/search.ts +14 -2
  52. package/src/gh-readonly.ts +0 -10
@@ -0,0 +1,277 @@
1
+ /**
2
+ * 本仓库工具注册的配置:`~/.pi/agent/settings.json` 的 `personalExtensions` section。
3
+ *
4
+ * ```jsonc
5
+ * {
6
+ * "personalExtensions": {
7
+ * "fileIo": "claude-code",
8
+ * "fileIoByModel": [{ "models": ["glm-*"], "fileIo": "opencode" }],
9
+ * "disabledTools": ["talk-*", { "tools": ["web_*"], "models": ["gpt-*"] }],
10
+ * "enabledTools": [{ "tools": ["web_search"], "models": ["glm-*"] }]
11
+ * }
12
+ * }
13
+ * ```
14
+ *
15
+ * 工具名与模型名都用 minimatch 通配模式匹配;模型名同时试 `model.id` 与
16
+ * `provider/model` 两种写法。带 `models` 的条目只在当前模型命中时才参与判定。
17
+ * 非法字段/条目被忽略并产出警告,不影响其余配置生效。
18
+ */
19
+
20
+ import { readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+
23
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
24
+ import { minimatch } from "minimatch";
25
+ import { Type } from "typebox";
26
+ import { Value } from "typebox/value";
27
+
28
+ // ── schema ───────────────────────────────────────────────────────────────────
29
+
30
+ const fileIoSchema = Type.Union([Type.Literal("claude-code"), Type.Literal("opencode")]);
31
+
32
+ const fileIoByModelEntrySchema = Type.Object({
33
+ models: Type.Array(Type.String()),
34
+ fileIo: fileIoSchema,
35
+ });
36
+
37
+ const toolRuleSchema = Type.Object({
38
+ tools: Type.Array(Type.String()),
39
+ models: Type.Optional(Type.Array(Type.String())),
40
+ });
41
+
42
+ // ── 类型 ─────────────────────────────────────────────────────────────────────
43
+
44
+ export type FileIoToolset = "claude-code" | "opencode";
45
+
46
+ export type ToolRuleField = "disabledTools" | "enabledTools";
47
+
48
+ export interface FileIoByModelEntry {
49
+ models: readonly string[];
50
+ fileIo: FileIoToolset;
51
+ }
52
+
53
+ export interface ToolPatternRule {
54
+ tools: readonly string[];
55
+ /** 空表示对所有模型生效。 */
56
+ models: readonly string[];
57
+ }
58
+
59
+ export interface ToolsConfig {
60
+ /** 兜底的文件 IO 工具集。 */
61
+ fileIo: FileIoToolset;
62
+ /** 按模型覆盖文件 IO 工具集,按顺序取首条命中。 */
63
+ fileIoByModel: readonly FileIoByModelEntry[];
64
+ disabledTools: readonly ToolPatternRule[];
65
+ enabledTools: readonly ToolPatternRule[];
66
+ /** 解析阶段发现的问题,由调用方在能提示用户时上报。 */
67
+ warnings: readonly string[];
68
+ }
69
+
70
+ export interface ModelIdentity {
71
+ id: string;
72
+ provider?: string;
73
+ }
74
+
75
+ export interface UnmatchedPattern {
76
+ field: ToolRuleField;
77
+ pattern: string;
78
+ }
79
+
80
+ export interface ToolAvailability {
81
+ /** 该工具名是否被禁用(命中 disabledTools 且未命中 enabledTools)。 */
82
+ isDisabled(name: string): boolean;
83
+ /** 哪些模式没匹配到任何工具(含被规则禁用的工具)。 */
84
+ unmatchedPatterns(declaredNames: readonly string[]): UnmatchedPattern[];
85
+ }
86
+
87
+ export const DEFAULT_FILE_IO: FileIoToolset = "claude-code";
88
+
89
+ // ── 读取与解析 ───────────────────────────────────────────────────────────────
90
+
91
+ function isRecord(value: unknown): value is Record<string, unknown> {
92
+ return typeof value === "object" && value !== null && !Array.isArray(value);
93
+ }
94
+
95
+ function parseFileIo(value: unknown, field: string, warnings: string[]): FileIoToolset | undefined {
96
+ if (value === undefined) {
97
+ return undefined;
98
+ }
99
+ if (Value.Check(fileIoSchema, value)) {
100
+ return value;
101
+ }
102
+ warnings.push(
103
+ `${field}: invalid value ${JSON.stringify(value)} ignored (expected "claude-code" or "opencode")`,
104
+ );
105
+ return undefined;
106
+ }
107
+
108
+ function parseFileIoByModel(
109
+ value: unknown,
110
+ field: string,
111
+ warnings: string[],
112
+ ): FileIoByModelEntry[] {
113
+ if (value === undefined) {
114
+ return [];
115
+ }
116
+ if (!Array.isArray(value)) {
117
+ warnings.push(`${field}: expected an array, ignored`);
118
+ return [];
119
+ }
120
+
121
+ const entries: FileIoByModelEntry[] = [];
122
+ for (const [index, entry] of value.entries()) {
123
+ if (Value.Check(fileIoByModelEntrySchema, entry)) {
124
+ entries.push({ models: [...entry.models], fileIo: entry.fileIo });
125
+ continue;
126
+ }
127
+ warnings.push(
128
+ `${field}[${index}]: invalid entry ignored (expected { models: string[], fileIo: "claude-code" | "opencode" })`,
129
+ );
130
+ }
131
+ return entries;
132
+ }
133
+
134
+ function parseToolRules(value: unknown, fieldPath: string, warnings: string[]): ToolPatternRule[] {
135
+ if (value === undefined) {
136
+ return [];
137
+ }
138
+ if (!Array.isArray(value)) {
139
+ warnings.push(`${fieldPath}: expected an array, ignored`);
140
+ return [];
141
+ }
142
+
143
+ const rules: ToolPatternRule[] = [];
144
+ for (const [index, entry] of value.entries()) {
145
+ if (typeof entry === "string") {
146
+ rules.push({ tools: [entry], models: [] });
147
+ continue;
148
+ }
149
+ if (Value.Check(toolRuleSchema, entry)) {
150
+ rules.push({ tools: [...entry.tools], models: entry.models ? [...entry.models] : [] });
151
+ continue;
152
+ }
153
+ warnings.push(
154
+ `${fieldPath}[${index}]: invalid entry ignored (expected a tool name pattern or { tools, models })`,
155
+ );
156
+ }
157
+ return rules;
158
+ }
159
+
160
+ /** 解析 `personalExtensions` section(纯函数,便于测试)。 */
161
+ export function parseToolsConfig(section: unknown, source = "personalExtensions"): ToolsConfig {
162
+ const warnings: string[] = [];
163
+
164
+ if (section === undefined) {
165
+ return {
166
+ fileIo: DEFAULT_FILE_IO,
167
+ fileIoByModel: [],
168
+ disabledTools: [],
169
+ enabledTools: [],
170
+ warnings,
171
+ };
172
+ }
173
+ if (!isRecord(section)) {
174
+ warnings.push(`${source}: expected an object, ignored`);
175
+ return {
176
+ fileIo: DEFAULT_FILE_IO,
177
+ fileIoByModel: [],
178
+ disabledTools: [],
179
+ enabledTools: [],
180
+ warnings,
181
+ };
182
+ }
183
+
184
+ return {
185
+ fileIo: parseFileIo(section.fileIo, `${source}.fileIo`, warnings) ?? DEFAULT_FILE_IO,
186
+ fileIoByModel: parseFileIoByModel(section.fileIoByModel, `${source}.fileIoByModel`, warnings),
187
+ disabledTools: parseToolRules(section.disabledTools, `${source}.disabledTools`, warnings),
188
+ enabledTools: parseToolRules(section.enabledTools, `${source}.enabledTools`, warnings),
189
+ warnings,
190
+ };
191
+ }
192
+
193
+ /** 读取全局 settings.json;文件缺失 / 损坏 / 无该 section 时用默认值。 */
194
+ export function readToolsConfig(settingsPath = join(getAgentDir(), "settings.json")): ToolsConfig {
195
+ let section: unknown;
196
+ try {
197
+ const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
198
+ section = isRecord(parsed) ? parsed.personalExtensions : undefined;
199
+ } catch {
200
+ return parseToolsConfig(undefined);
201
+ }
202
+ return parseToolsConfig(section);
203
+ }
204
+
205
+ // ── 判定 ─────────────────────────────────────────────────────────────────────
206
+
207
+ function matchesPattern(pattern: string, value: string): boolean {
208
+ return minimatch(value, pattern);
209
+ }
210
+
211
+ function modelCandidates(model: ModelIdentity | undefined): string[] {
212
+ if (!model) {
213
+ return [];
214
+ }
215
+ return model.provider ? [model.id, `${model.provider}/${model.id}`] : [model.id];
216
+ }
217
+
218
+ function matchesModel(patterns: readonly string[], model: ModelIdentity | undefined): boolean {
219
+ if (patterns.length === 0) {
220
+ return true;
221
+ }
222
+ const candidates = modelCandidates(model);
223
+ return patterns.some((pattern) =>
224
+ candidates.some((candidate) => matchesPattern(pattern, candidate)),
225
+ );
226
+ }
227
+
228
+ function ruleMatches(
229
+ rule: ToolPatternRule,
230
+ name: string,
231
+ model: ModelIdentity | undefined,
232
+ ): boolean {
233
+ return (
234
+ rule.tools.some((pattern) => matchesPattern(pattern, name)) && matchesModel(rule.models, model)
235
+ );
236
+ }
237
+
238
+ export function resolveFileIoToolset(
239
+ config: ToolsConfig,
240
+ model: ModelIdentity | undefined,
241
+ ): FileIoToolset {
242
+ for (const entry of config.fileIoByModel) {
243
+ if (matchesModel(entry.models, model)) {
244
+ return entry.fileIo;
245
+ }
246
+ }
247
+ return config.fileIo;
248
+ }
249
+
250
+ export function resolveToolAvailability(
251
+ config: ToolsConfig,
252
+ model: ModelIdentity | undefined,
253
+ ): ToolAvailability {
254
+ return {
255
+ isDisabled(name) {
256
+ const disabled = config.disabledTools.some((rule) => ruleMatches(rule, name, model));
257
+ if (!disabled) {
258
+ return false;
259
+ }
260
+ return config.enabledTools.every((rule) => !ruleMatches(rule, name, model));
261
+ },
262
+
263
+ unmatchedPatterns(declaredNames) {
264
+ const unmatched: UnmatchedPattern[] = [];
265
+ for (const field of ["disabledTools", "enabledTools"] as const) {
266
+ for (const rule of config[field]) {
267
+ for (const pattern of rule.tools) {
268
+ if (declaredNames.every((name) => !matchesPattern(pattern, name))) {
269
+ unmatched.push({ field, pattern });
270
+ }
271
+ }
272
+ }
273
+ }
274
+ return unmatched;
275
+ },
276
+ };
277
+ }
@@ -13,6 +13,8 @@ import {
13
13
  } from "../bwrap/runtime.js";
14
14
  import { resolveWorkdir } from "../lib/path.js";
15
15
  import { createRequestPolicy } from "../lib/request-policy.js";
16
+ import type { ToolBus } from "../lib/tool-bus.js";
17
+ import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
16
18
 
17
19
  const DEFAULT_TIMEOUT_MS = 120_000;
18
20
  const MAX_TIMEOUT_MS = 7_200_000;
@@ -48,10 +50,13 @@ export default function opencodeBash(
48
50
  pi: ExtensionAPI,
49
51
  runtime: BwrapRuntime = createBwrapRuntime(createRequestPolicy(pi.events)),
50
52
  ): void {
53
+ registerToolsOnSessionStart(pi, (bus) => registerBashTool(bus, pi, runtime));
54
+ }
55
+
56
+ export function registerBashTool(bus: ToolBus, pi: ExtensionAPI, runtime: BwrapRuntime): void {
51
57
  // 每个扩展实例持有自己的 runtime:不依赖模块级全局状态,状态随扩展
52
58
  // 实例生命周期(进程启动 / /reload / session 切换时工厂重建即重置)。
53
- runtime.setup(pi);
54
- pi.registerTool({
59
+ bus.register({
55
60
  name: "bash",
56
61
  label: "bash",
57
62
  description: [
@@ -30,6 +30,7 @@ import { createInterface } from "node:readline";
30
30
  import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
31
31
  import {
32
32
  type ExtensionAPI,
33
+ type ExtensionContext,
33
34
  generateDiffString,
34
35
  generateUnifiedPatch,
35
36
  withFileMutationQueue,
@@ -50,10 +51,17 @@ import {
50
51
  } from "../lib/file-reads.js";
51
52
  import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
52
53
  import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
53
- import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
54
+ import {
55
+ createLspManager,
56
+ type LspManager,
57
+ type LspService,
58
+ type LspServiceOptions,
59
+ } from "../lib/lsp/lsp.js";
54
60
  import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
55
61
  import { formatSubtitlePath } from "../lib/path.js";
56
62
  import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
63
+ import type { ToolBus } from "../lib/tool-bus.js";
64
+ import { createToolRegistration } from "../lib/tool-registration.js";
57
65
  import { guardWriteAccess } from "../lib/write-guard.js";
58
66
  import { applyEdit, normalizeToLF, stripBom } from "./edit-engine.js";
59
67
 
@@ -402,8 +410,8 @@ async function formatDirectoryEntries(dirPath: string): Promise<string[]> {
402
410
  /** 会更新 reads 记账(src/lib/file-reads.ts)并随 details 持久化快照的工具名。 */
403
411
  const READS_TOOL_NAMES = new Set(["read", "edit", "write", "lsp-rename"]);
404
412
 
405
- function registerReadTool(pi: ExtensionAPI, getService: () => LspService, state: ReadsState): void {
406
- pi.registerTool({
413
+ function registerReadTool(bus: ToolBus, getService: () => LspService, state: ReadsState): void {
414
+ bus.register({
407
415
  name: "read",
408
416
  label: "read",
409
417
  description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. A negative offset counts from the end of the file (offset=-2 reads the last 2 lines). When you need the full file, continue with offset until complete.`,
@@ -598,12 +606,12 @@ const editSchema = Type.Object({
598
606
  });
599
607
 
600
608
  function registerEditTool(
601
- pi: ExtensionAPI,
609
+ bus: ToolBus,
602
610
  getService: () => LspService,
603
611
  policy: RequestPolicy,
604
612
  state: ReadsState,
605
613
  ): void {
606
- pi.registerTool({
614
+ bus.register({
607
615
  name: "edit",
608
616
  label: "edit",
609
617
  description:
@@ -775,12 +783,12 @@ export function resolveBom(
775
783
  }
776
784
 
777
785
  function registerWriteTool(
778
- pi: ExtensionAPI,
786
+ bus: ToolBus,
779
787
  getService: () => LspService,
780
788
  policy: RequestPolicy,
781
789
  state: ReadsState,
782
790
  ): void {
783
- pi.registerTool({
791
+ bus.register({
784
792
  name: "write",
785
793
  label: "write",
786
794
  description:
@@ -864,64 +872,103 @@ function registerWriteTool(
864
872
  // ── 入口 ─────────────────────────────────────────────────────────────────────
865
873
 
866
874
  function registerFileTools(
867
- pi: ExtensionAPI,
875
+ bus: ToolBus,
868
876
  getService: () => LspService,
869
877
  policy: RequestPolicy,
870
878
  state: ReadsState,
871
879
  ): void {
872
- registerReadTool(pi, getService, state);
873
- registerEditTool(pi, getService, policy, state);
874
- registerWriteTool(pi, getService, policy, state);
880
+ registerReadTool(bus, getService, state);
881
+ registerEditTool(bus, getService, policy, state);
882
+ registerWriteTool(bus, getService, policy, state);
875
883
  // lsp-rename / inspect 工具由 manager 的 onEnabled 回调注册,不在这里注册。
876
884
  }
877
885
 
878
886
  export interface OpencodeFileToolOptions extends LspServiceOptions {
879
887
  /** 与 bash runtime 共享的非沙盒请求策略;独立入口不传,自建一份。 */
880
888
  policy?: RequestPolicy;
889
+ /** 聚合入口注入的总线(与同入口其他模块共享);独立入口不传,自建一份。 */
890
+ bus?: ToolBus;
891
+ /** 聚合入口注入的共享 LSP manager(与另一套文件工具集共享同一份)。 */
892
+ manager?: LspManager;
881
893
  }
882
894
 
883
- /** 独立入口:创建 LSP manager(session_start 时按配置启用)并注册文件工具。 */
884
- export default function opencodeFileTools(
895
+ /** 一套文件工具集:reads state 归实例所有,LSP 与请求策略可以由入口注入共享。 */
896
+ export interface OpencodeFileToolset {
897
+ register(bus: ToolBus): void;
898
+ onLspEnabled(bus: ToolBus, service: LspService): void;
899
+ restoreReads(ctx: ExtensionContext): void;
900
+ }
901
+
902
+ /**
903
+ * 创建文件工具集。入口在会话启动、知道本会话模型之后再调用 `register`。
904
+ */
905
+ export function createOpencodeFileTools(
885
906
  pi: ExtensionAPI,
886
907
  options?: OpencodeFileToolOptions,
887
- ): void {
888
- // 聚合入口(index.ts)注入与 bash runtime 共享的那一份;独立入口自建并靠
889
- // pi.events 跟随同一开关。
908
+ ): OpencodeFileToolset {
909
+ // 聚合入口注入与 bash runtime 共享的那一份;独立入口自建并靠 pi.events 跟随
910
+ // 同一开关。
890
911
  const policy = options?.policy ?? createRequestPolicy(pi.events);
891
912
  const state = createReadsState();
892
- const manager = createLspManager(
893
- pi,
894
- {
895
- onEnabled: (pi, service) => {
896
- registerLspRenameTool(pi, service, {
897
- policy,
898
- recordReads: (applied) =>
899
- recordReads(
900
- state,
901
- applied.map((fileEdit) => ({
902
- path: fileEdit.path,
903
- snapshot: snapshotOf(fileEdit.newText),
904
- })),
905
- ),
906
- });
907
- registerLspInspectTools(pi, service);
908
- },
913
+ // 聚合入口注入自己的总线(与同入口其他模块共享);独立入口自建一份。
914
+ const bus = options?.bus ?? createToolRegistration(pi).bus;
915
+
916
+ const toolset: OpencodeFileToolset = {
917
+ register(currentBus) {
918
+ registerFileTools(currentBus, () => manager.mustLazyGetService(), policy, state);
919
+ },
920
+
921
+ onLspEnabled(currentBus, service) {
922
+ registerLspRenameTool(currentBus, service, {
923
+ policy,
924
+ recordReads: (applied) =>
925
+ recordReads(
926
+ state,
927
+ applied.map((fileEdit) => ({
928
+ path: fileEdit.path,
929
+ snapshot: snapshotOf(fileEdit.newText),
930
+ })),
931
+ ),
932
+ });
933
+ registerLspInspectTools(currentBus, service);
934
+ },
935
+
936
+ // 扩展实例在进程启动 / /reload / /new / /resume / /fork 时重建,内存里的已读
937
+ // 记账随之丢失,这里从当前分支的历史工具结果恢复;digest 仍是当时的值,文件
938
+ // 若在此期间被外部修改,edit 时的指纹对比照样要求重新 read。
939
+ restoreReads(ctx) {
940
+ restoreReads(state, ctx.sessionManager, READS_TOOL_NAMES);
909
941
  },
910
- options,
911
- );
942
+ };
943
+
944
+ const manager =
945
+ options?.manager ??
946
+ createLspManager(
947
+ pi,
948
+ { onEnabled: (_pi, service) => toolset.onLspEnabled(bus, service) },
949
+ options,
950
+ );
951
+
952
+ return toolset;
953
+ }
954
+
955
+ /** 独立入口:创建文件工具集并在每次会话启动时注册。 */
956
+ export default function opencodeFileTools(
957
+ pi: ExtensionAPI,
958
+ options?: OpencodeFileToolOptions,
959
+ ): void {
960
+ const registration = createToolRegistration(pi);
961
+ const toolset = createOpencodeFileTools(pi, { ...options, bus: registration.bus });
912
962
 
913
- // 扩展实例在进程启动 / /reload / /new / /resume / /fork 时重建,内存里的已读
914
- // 记账随之丢失,这里从当前分支的历史工具结果恢复;digest 仍是当时的值,文件
915
- // 若在此期间被外部修改,edit 时的指纹对比照样要求重新 read。
916
963
  pi.on("session_start", (_event, ctx) => {
917
- restoreReads(state, ctx.sessionManager, READS_TOOL_NAMES);
964
+ toolset.restoreReads(ctx);
918
965
  });
919
966
  // rewind / 树内跳转走 branch(),只发 session_tree:重放当前分支,丢弃被抛弃
920
967
  // 分支的记账,避免 state 与当前分支脱节。
921
968
  pi.on("session_tree", (_event, ctx) => {
922
- restoreReads(state, ctx.sessionManager, READS_TOOL_NAMES);
969
+ toolset.restoreReads(ctx);
970
+ });
971
+ registration.onSessionStart(() => {
972
+ toolset.register(registration.bus);
923
973
  });
924
-
925
- // 文件工具无条件注册;service 惰性获取,disabled 时为 no-op。
926
- registerFileTools(pi, () => manager.mustLazyGetService(), policy, state);
927
974
  }
@@ -18,6 +18,8 @@ import { fileURLToPath } from "node:url";
18
18
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
19
  import { Type } from "typebox";
20
20
 
21
+ import type { ToolBus } from "../lib/tool-bus.js";
22
+ import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
21
23
  import { didYouMean } from "./files.js";
22
24
  import { RIPGREP_RESULT_LIMIT, runRipgrep } from "./ripgrep.js";
23
25
 
@@ -82,7 +84,11 @@ async function glob(
82
84
  }
83
85
 
84
86
  export default function opencodeGlob(pi: ExtensionAPI): void {
85
- pi.registerTool({
87
+ registerToolsOnSessionStart(pi, registerGlobTool);
88
+ }
89
+
90
+ export function registerGlobTool(bus: ToolBus): void {
91
+ bus.register({
86
92
  name: "glob",
87
93
  label: "glob",
88
94
  description: [
@@ -21,6 +21,8 @@ import { Type } from "typebox";
21
21
 
22
22
  import { isRecord } from "../lib/narrow.js";
23
23
  import { parseWithSchema } from "../lib/parse-with-schema.js";
24
+ import type { ToolBus } from "../lib/tool-bus.js";
25
+ import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
24
26
  import { didYouMean } from "./files.js";
25
27
  import { RIPGREP_RESULT_LIMIT, runRipgrep } from "./ripgrep.js";
26
28
 
@@ -178,7 +180,11 @@ async function grep(
178
180
  }
179
181
 
180
182
  export default function opencodeGrep(pi: ExtensionAPI): void {
181
- pi.registerTool({
183
+ registerToolsOnSessionStart(pi, registerGrepTool);
184
+ }
185
+
186
+ export function registerGrepTool(bus: ToolBus): void {
187
+ bus.register({
182
188
  name: "grep",
183
189
  label: "grep",
184
190
  description: [
@@ -11,25 +11,70 @@
11
11
  * allowlist 只暴露声明的子集(与 claude-code 三件套映射同一文件一致)。
12
12
  */
13
13
 
14
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
-
16
- import { createBwrapRuntime } from "../bwrap/runtime.js";
17
- import { createRequestPolicy } from "../lib/request-policy.js";
18
- import opencodeBash from "./bash.js";
19
- import opencodeFileTools from "./files.js";
20
- import opencodeGlob from "./glob.js";
21
- import opencodeGrep from "./grep.js";
22
- import opencodeQuestion from "./question.js";
23
- import opencodeTodo from "./todo.js";
24
-
25
- export default function opencode(pi: ExtensionAPI) {
26
- // 非沙盒请求策略由本入口创建,注入文件工具与 bash runtime 共享同一份;
27
- // 独立入口(web/fetch.ts 等)各自创建一份并经 pi.events 保持同步。
28
- const policy = createRequestPolicy(pi.events);
29
- opencodeFileTools(pi, { policy });
30
- opencodeGrep(pi);
31
- opencodeGlob(pi);
32
- opencodeTodo(pi);
33
- opencodeQuestion(pi);
34
- opencodeBash(pi, createBwrapRuntime(policy));
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+
16
+ import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
17
+ import type { LspManager } from "../lib/lsp/lsp.js";
18
+ import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
19
+ import type { ToolBus } from "../lib/tool-bus.js";
20
+ import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
21
+ import { registerBashTool } from "./bash.js";
22
+ import { createOpencodeFileTools } from "./files.js";
23
+ import { registerGlobTool } from "./glob.js";
24
+ import { registerGrepTool } from "./grep.js";
25
+ import { registerQuestionTool } from "./question.js";
26
+ import { registerTodoTool } from "./todo.js";
27
+
28
+ export interface OpencodeToolsetOptions {
29
+ bus?: ToolBus;
30
+ policy?: RequestPolicy;
31
+ runtime?: BwrapRuntime;
32
+ manager?: LspManager;
33
+ }
34
+
35
+ /** opencode 风格工具集的共享部件:入口与独立默认导出都从这里取。 */
36
+ export function createOpencodeTools(pi: ExtensionAPI, options?: OpencodeToolsetOptions) {
37
+ // 非沙盒请求策略由本工具集创建,注入文件工具与 bash runtime 共享同一份。
38
+ const policy = options?.policy ?? createRequestPolicy(pi.events);
39
+ const runtime = options?.runtime ?? createBwrapRuntime(policy);
40
+ // 独立入口(不经 src/index.ts 的共享服务)要自己接上 bwrap 的会话钩子与 /bwrap* 命令。
41
+ if (options?.runtime === undefined) {
42
+ runtime.setup(pi);
43
+ }
44
+ const fileTools = createOpencodeFileTools(pi, {
45
+ policy,
46
+ bus: options?.bus,
47
+ manager: options?.manager,
48
+ });
49
+
50
+ return {
51
+ fileTools,
52
+ /** 会话启动 / 分支切换时恢复已读记账。 */
53
+ restoreReads(ctx: ExtensionContext): void {
54
+ fileTools.restoreReads(ctx);
55
+ },
56
+ /** 把本工具集的工具注册到总线。 */
57
+ register(bus: ToolBus): void {
58
+ fileTools.register(bus);
59
+ registerGrepTool(bus);
60
+ registerGlobTool(bus);
61
+ registerTodoTool(bus);
62
+ registerQuestionTool(bus);
63
+ registerBashTool(bus, pi, runtime);
64
+ },
65
+ };
66
+ }
67
+
68
+ export default function opencode(pi: ExtensionAPI, options?: { bus?: ToolBus }): void {
69
+ const tools = createOpencodeTools(pi, { bus: options?.bus });
70
+
71
+ pi.on("session_start", (_event, ctx) => tools.restoreReads(ctx));
72
+ // rewind / 树内跳转只发 session_tree 不发 session_start,同样要重放当前分支。
73
+ pi.on("session_tree", (_event, ctx) => tools.restoreReads(ctx));
74
+
75
+ // 全部工具在同一次 session_start 里注册:禁用规则可以带 models,只有那时
76
+ // 才知道本会话的模型。
77
+ registerToolsOnSessionStart(pi, (bus) => {
78
+ tools.register(bus);
79
+ });
35
80
  }
@@ -24,6 +24,8 @@
24
24
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
25
25
  import { type Static, Type } from "typebox";
26
26
 
27
+ import type { ToolBus } from "../lib/tool-bus.js";
28
+ import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
27
29
  import { selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
28
30
 
29
31
  // ── constants ────────────────────────────────────────────────────────────────
@@ -145,8 +147,12 @@ async function askMultiple(
145
147
 
146
148
  // ── extension ────────────────────────────────────────────────────────────────
147
149
 
148
- export default function question(pi: ExtensionAPI) {
149
- pi.registerTool<typeof questionParamsSchema, QuestionDetails>({
150
+ export default function question(pi: ExtensionAPI): void {
151
+ registerToolsOnSessionStart(pi, registerQuestionTool);
152
+ }
153
+
154
+ export function registerQuestionTool(bus: ToolBus): void {
155
+ bus.register<typeof questionParamsSchema, QuestionDetails>({
150
156
  name: TOOL_NAME,
151
157
  label: "Question",
152
158
  description: QUESTION_DESCRIPTION,