@trim21/personal-pi-extensions 0.0.240 → 0.0.242
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 +1 -1
- package/src/bwrap/core.ts +7 -1
- package/src/bwrap/runtime.ts +31 -5
- package/src/claude-code/common.ts +22 -0
- package/src/claude-code/files.ts +20 -6
- package/src/claude-code/index.ts +21 -1
- package/src/claude-code/session-tools.ts +23 -11
- package/src/gh-readonly.ts +31 -1
package/package.json
CHANGED
package/src/bwrap/core.ts
CHANGED
|
@@ -167,7 +167,13 @@ function findDefaultBwrap(): string {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
export function findBwrap(override?: string): string {
|
|
170
|
-
|
|
170
|
+
if (override) {
|
|
171
|
+
if (!existsSync(override)) {
|
|
172
|
+
throw new Error(`bwrap not found at configured path: ${override}`);
|
|
173
|
+
}
|
|
174
|
+
return override;
|
|
175
|
+
}
|
|
176
|
+
return findDefaultBwrap();
|
|
171
177
|
}
|
|
172
178
|
|
|
173
179
|
export function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): string[] {
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -73,6 +73,7 @@ function notifyMode(
|
|
|
73
73
|
export class BwrapRuntime {
|
|
74
74
|
private resolved: ResolvedBwrap | undefined;
|
|
75
75
|
private sandboxDisabled = false;
|
|
76
|
+
private bwrapUnavailable = false;
|
|
76
77
|
|
|
77
78
|
setup(pi: ExtensionAPI): void {
|
|
78
79
|
pi.registerFlag("no-bwrap", {
|
|
@@ -84,13 +85,18 @@ export class BwrapRuntime {
|
|
|
84
85
|
pi.on("session_start", (_event, ctx) => {
|
|
85
86
|
this.sandboxDisabled = pi.getFlag("no-bwrap") === true && ctx.hasUI;
|
|
86
87
|
this.resolved = undefined;
|
|
88
|
+
this.bwrapUnavailable = false;
|
|
87
89
|
const runtime = this.resolve(ctx);
|
|
88
90
|
if (runtime.bwrapEnabled) {
|
|
89
91
|
try {
|
|
90
92
|
findBwrap(runtime.bwrapPath);
|
|
91
93
|
} catch (error) {
|
|
92
|
-
|
|
94
|
+
// Fail closed: a missing bwrap binary must not silently degrade to an
|
|
95
|
+
// unsandboxed allow-all session. Commands are refused until the user
|
|
96
|
+
// explicitly opts out via --no-bwrap or the bwrap-allow-all command.
|
|
97
|
+
this.bwrapUnavailable = true;
|
|
93
98
|
this.resolved = undefined;
|
|
99
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("error", "bwrap: unavailable"));
|
|
94
100
|
ctx.ui.notify(error instanceof Error ? error.message : "bwrap not found", "error");
|
|
95
101
|
return;
|
|
96
102
|
}
|
|
@@ -110,10 +116,16 @@ export class BwrapRuntime {
|
|
|
110
116
|
|
|
111
117
|
pi.on("before_agent_start", (event, ctx) => {
|
|
112
118
|
const runtime = this.resolve(ctx);
|
|
113
|
-
const
|
|
114
|
-
?
|
|
115
|
-
: "
|
|
116
|
-
|
|
119
|
+
const modeText = ctx.hasUI
|
|
120
|
+
? `Current bwrap mode: **${runtime.mode}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.`
|
|
121
|
+
: "This headless session is forced into bwrap readonly mode. Unsandboxed execution cannot be approved.";
|
|
122
|
+
const unavailableText = this.bwrapUnavailable
|
|
123
|
+
? " bwrap is unavailable (binary not found): bash commands are refused unless the user explicitly approves unsandboxed execution."
|
|
124
|
+
: "";
|
|
125
|
+
return {
|
|
126
|
+
systemPrompt:
|
|
127
|
+
event.systemPrompt + `\n\n## Command Execution\n${modeText}${unavailableText}\n`,
|
|
128
|
+
};
|
|
117
129
|
});
|
|
118
130
|
|
|
119
131
|
this.registerCommands(pi);
|
|
@@ -128,10 +140,17 @@ export class BwrapRuntime {
|
|
|
128
140
|
reset(): void {
|
|
129
141
|
this.resolved = undefined;
|
|
130
142
|
this.sandboxDisabled = false;
|
|
143
|
+
this.bwrapUnavailable = false;
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
async execute(request: BwrapExecutionRequest) {
|
|
134
147
|
const runtime = this.resolve(request.ctx);
|
|
148
|
+
if (this.bwrapUnavailable && runtime.bwrapEnabled && request.requestFullAccess !== true) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
"bwrap (bubblewrap) not found; refusing to execute commands without sandboxing. " +
|
|
151
|
+
"Install bubblewrap and restart the session, or pass --no-bwrap to disable the sandbox explicitly.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
135
154
|
if (request.requestFullAccess === true && runtime.bwrapEnabled) {
|
|
136
155
|
await this.approveFullAccess(request.ctx, request.command, request.requestFullAccessReason);
|
|
137
156
|
}
|
|
@@ -229,6 +248,13 @@ export class BwrapRuntime {
|
|
|
229
248
|
handler: (args, ctx) =>
|
|
230
249
|
this.runCommand(pi, specs.bwrap, args, ctx, (commandCtx) => {
|
|
231
250
|
const runtime = this.resolve(commandCtx);
|
|
251
|
+
if (this.bwrapUnavailable) {
|
|
252
|
+
commandCtx.ui.notify(
|
|
253
|
+
"bwrap is unavailable: binary not found. Commands are refused unless sandboxing is explicitly disabled.",
|
|
254
|
+
"error",
|
|
255
|
+
);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
232
258
|
if (!runtime.bwrapEnabled) {
|
|
233
259
|
commandCtx.ui.notify(`bwrap disabled (mode: ${runtime.mode})`, "info");
|
|
234
260
|
return;
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { isAbsolute, normalize, resolve } from "node:path";
|
|
2
2
|
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { Value } from "typebox/value";
|
|
5
|
+
|
|
3
6
|
export interface FileSnapshot {
|
|
4
7
|
digest: string;
|
|
5
8
|
textEditable: boolean;
|
|
6
9
|
}
|
|
7
10
|
|
|
11
|
+
const fileSnapshotSchema = Type.Object({
|
|
12
|
+
digest: Type.String(),
|
|
13
|
+
textEditable: Type.Boolean(),
|
|
14
|
+
});
|
|
15
|
+
|
|
8
16
|
export interface ClaudeCodeState {
|
|
9
17
|
readonly reads: Map<string, FileSnapshot>;
|
|
10
18
|
}
|
|
@@ -33,3 +41,17 @@ export function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
|
33
41
|
export function snapshotsEqual(left: FileSnapshot, right: FileSnapshot): boolean {
|
|
34
42
|
return left.digest === right.digest;
|
|
35
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 从工具结果 details 里恢复文件已读记账(跨进程 resume / reload / fork)。
|
|
47
|
+
* 数据来自 session 文件,可能缺失或损坏:逐条 TypeBox 校验,非法条目丢弃。
|
|
48
|
+
* 只接受 plain object,数组、null 等异常形态直接返回空 map。
|
|
49
|
+
*/
|
|
50
|
+
export function deserializeReads(data: unknown): Map<string, FileSnapshot> {
|
|
51
|
+
const reads = new Map<string, FileSnapshot>();
|
|
52
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return reads;
|
|
53
|
+
for (const [filePath, snapshot] of Object.entries(data)) {
|
|
54
|
+
if (Value.Check(fileSnapshotSchema, snapshot)) reads.set(filePath, snapshot);
|
|
55
|
+
}
|
|
56
|
+
return reads;
|
|
57
|
+
}
|
package/src/claude-code/files.ts
CHANGED
|
@@ -44,6 +44,11 @@ export interface FileToolDetails {
|
|
|
44
44
|
diff?: string;
|
|
45
45
|
patch?: string;
|
|
46
46
|
firstChangedLine?: number;
|
|
47
|
+
/**
|
|
48
|
+
* 本轮受影响文件的已读快照(path → snapshot)。随工具结果持久化到
|
|
49
|
+
* session 文件,resume 后由 session_start 重建 reads state。
|
|
50
|
+
*/
|
|
51
|
+
reads?: Record<string, FileSnapshot>;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
function snapshotOf(content: Uint8Array | string, textEditable = true): FileSnapshot {
|
|
@@ -216,8 +221,9 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
|
|
|
216
221
|
{ type: "text", text: `Read image file [${imageMime}]` },
|
|
217
222
|
{ type: "image", data, mimeType: imageMime },
|
|
218
223
|
];
|
|
219
|
-
|
|
220
|
-
|
|
224
|
+
const snapshot = snapshotOf(image, false);
|
|
225
|
+
state.reads.set(filePath, snapshot);
|
|
226
|
+
return { content, details: { reads: { [filePath]: snapshot } } };
|
|
221
227
|
}
|
|
222
228
|
|
|
223
229
|
const buffer = await readFile(filePath);
|
|
@@ -228,8 +234,12 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
|
|
|
228
234
|
params.offset ?? 1,
|
|
229
235
|
params.limit ?? DEFAULT_READ_LINES,
|
|
230
236
|
);
|
|
231
|
-
|
|
232
|
-
|
|
237
|
+
const snapshot = snapshotOf(buffer);
|
|
238
|
+
state.reads.set(filePath, snapshot);
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: "text", text: formatted.text }],
|
|
241
|
+
details: { reads: { [filePath]: snapshot } },
|
|
242
|
+
};
|
|
233
243
|
},
|
|
234
244
|
});
|
|
235
245
|
|
|
@@ -279,7 +289,8 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
|
|
|
279
289
|
params.replace_all ?? false,
|
|
280
290
|
);
|
|
281
291
|
await writeFile(filePath, updated, "utf8");
|
|
282
|
-
|
|
292
|
+
const snapshot = snapshotOf(updated);
|
|
293
|
+
state.reads.set(filePath, snapshot);
|
|
283
294
|
const diff = generateDiffString(original, updated);
|
|
284
295
|
return {
|
|
285
296
|
content: [{ type: "text", text: `The file ${filePath} has been updated successfully.` }],
|
|
@@ -287,6 +298,7 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
|
|
|
287
298
|
diff: diff.diff,
|
|
288
299
|
patch: generateUnifiedPatch(filePath, original, updated),
|
|
289
300
|
firstChangedLine: diff.firstChangedLine,
|
|
301
|
+
reads: { [filePath]: snapshot },
|
|
290
302
|
} satisfies FileToolDetails,
|
|
291
303
|
};
|
|
292
304
|
});
|
|
@@ -334,7 +346,8 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
|
|
|
334
346
|
throwIfAborted(signal);
|
|
335
347
|
await mkdir(dirname(filePath), { recursive: true });
|
|
336
348
|
await writeFile(filePath, params.content, "utf8");
|
|
337
|
-
|
|
349
|
+
const snapshot = snapshotOf(params.content);
|
|
350
|
+
state.reads.set(filePath, snapshot);
|
|
338
351
|
const diff = generateDiffString(original ?? "", params.content);
|
|
339
352
|
return {
|
|
340
353
|
content: [{ type: "text", text: `File created successfully at: ${filePath}` }],
|
|
@@ -342,6 +355,7 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
|
|
|
342
355
|
diff: diff.diff,
|
|
343
356
|
patch: generateUnifiedPatch(filePath, original ?? "", params.content),
|
|
344
357
|
firstChangedLine: diff.firstChangedLine,
|
|
358
|
+
reads: { [filePath]: snapshot },
|
|
345
359
|
} satisfies FileToolDetails,
|
|
346
360
|
};
|
|
347
361
|
});
|
package/src/claude-code/index.ts
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
import { createClaudeCodeState } from "./common.js";
|
|
3
|
+
import { createClaudeCodeState, deserializeReads } from "./common.js";
|
|
4
4
|
import { registerFileTools } from "./files.js";
|
|
5
5
|
import { registerSearchTools } from "./search.js";
|
|
6
6
|
import { registerSessionTools } from "./session-tools.js";
|
|
7
7
|
import { registerShellTools } from "./shell.js";
|
|
8
8
|
|
|
9
|
+
/** 会更新 reads state 并随 details 持久化快照的工具名。 */
|
|
10
|
+
const FILE_TOOL_NAMES = new Set(["Read", "Edit", "Write"]);
|
|
11
|
+
|
|
9
12
|
export default function claudeCodeTools(pi: ExtensionAPI): void {
|
|
10
13
|
const state = createClaudeCodeState();
|
|
14
|
+
|
|
15
|
+
// 扩展实例在进程启动 / /reload / /new / /resume / /fork 时重建,内存里的
|
|
16
|
+
// 已读记账随之丢失。这里从当前分支的历史工具结果里恢复:digest 是当时的值,
|
|
17
|
+
// 若文件在此期间被外部修改,Edit/Write 时的指纹对比仍会要求重新 Read,
|
|
18
|
+
// 防呆语义不因重建而弱化。
|
|
19
|
+
pi.on("session_start", (_event, ctx) => {
|
|
20
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
21
|
+
if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
|
|
22
|
+
if (!FILE_TOOL_NAMES.has(entry.message.toolName)) continue;
|
|
23
|
+
const details = entry.message.details as { reads?: unknown } | undefined;
|
|
24
|
+
if (!details?.reads) continue;
|
|
25
|
+
for (const [filePath, snapshot] of deserializeReads(details.reads)) {
|
|
26
|
+
state.reads.set(filePath, snapshot);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
11
31
|
registerFileTools(pi, state);
|
|
12
32
|
registerSearchTools(pi);
|
|
13
33
|
registerShellTools(pi);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
+
import { Value } from "typebox/value";
|
|
4
5
|
|
|
5
6
|
import { selectWithOptionalInput } from "../lib/ui.js";
|
|
6
7
|
|
|
@@ -102,23 +103,34 @@ async function askMultiple(
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
export function registerSessionTools(pi: ExtensionAPI): void {
|
|
106
|
+
const todoItemSchema = Type.Object(
|
|
107
|
+
{
|
|
108
|
+
content: Type.String({ minLength: 1 }),
|
|
109
|
+
status: StringEnum(TODO_STATUSES),
|
|
110
|
+
activeForm: Type.String({ minLength: 1 }),
|
|
111
|
+
},
|
|
112
|
+
{ additionalProperties: false },
|
|
113
|
+
);
|
|
105
114
|
const todoSchema = Type.Object(
|
|
106
115
|
{
|
|
107
|
-
todos: Type.Array(
|
|
108
|
-
Type.Object(
|
|
109
|
-
{
|
|
110
|
-
content: Type.String({ minLength: 1 }),
|
|
111
|
-
status: StringEnum(TODO_STATUSES),
|
|
112
|
-
activeForm: Type.String({ minLength: 1 }),
|
|
113
|
-
},
|
|
114
|
-
{ additionalProperties: false },
|
|
115
|
-
),
|
|
116
|
-
{ description: "The updated todo list" },
|
|
117
|
-
),
|
|
116
|
+
todos: Type.Array(todoItemSchema, { description: "The updated todo list" }),
|
|
118
117
|
},
|
|
119
118
|
{ additionalProperties: false },
|
|
120
119
|
);
|
|
121
120
|
|
|
121
|
+
// TodoWrite 的列表随工具结果 details 持久化(跟随会话分支),但 widget 是
|
|
122
|
+
// 纯 TUI 状态,进程重启后丢失。session 恢复时从当前分支取最后一个 TodoWrite
|
|
123
|
+
// 的列表重新渲染(完整列表替换语义,后出现的覆盖前面的)。
|
|
124
|
+
pi.on("session_start", (_event, ctx) => {
|
|
125
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
126
|
+
if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
|
|
127
|
+
if (entry.message.toolName !== "TodoWrite") continue;
|
|
128
|
+
if (Value.Check(todoSchema, entry.message.details)) {
|
|
129
|
+
ctx.ui.setWidget("claude-code-todos", formatTodos(entry.message.details.todos));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
122
134
|
pi.registerTool({
|
|
123
135
|
name: "TodoWrite",
|
|
124
136
|
label: "Todo Write",
|
package/src/gh-readonly.ts
CHANGED
|
@@ -29,9 +29,10 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { spawn } from "node:child_process";
|
|
32
|
+
import { existsSync } from "node:fs";
|
|
32
33
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
33
34
|
import { homedir } from "node:os";
|
|
34
|
-
import { dirname, join, resolve } from "node:path";
|
|
35
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
35
36
|
|
|
36
37
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
37
38
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
@@ -52,6 +53,22 @@ interface GhResult {
|
|
|
52
53
|
|
|
53
54
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Check whether the `gh` CLI is on the system, scanning PATH like
|
|
58
|
+
* `findDefaultBwrap`. The extension registers no tools when `gh` is missing, so
|
|
59
|
+
* the model never sees GitHub tools that would fail on every call.
|
|
60
|
+
*/
|
|
61
|
+
export function isGhAvailable(): boolean {
|
|
62
|
+
const pathEnv = process.env.PATH ?? "";
|
|
63
|
+
for (const directory of pathEnv.split(delimiter)) {
|
|
64
|
+
if (existsSync(join(directory, "gh"))) return true;
|
|
65
|
+
}
|
|
66
|
+
for (const candidate of ["/usr/bin/gh", "/usr/local/bin/gh", "/run/current-system/sw/bin/gh"]) {
|
|
67
|
+
if (existsSync(candidate)) return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
export function runGh(
|
|
56
73
|
args: string[],
|
|
57
74
|
ctx: { cwd?: string; signal?: AbortSignal; timeout?: number },
|
|
@@ -1082,6 +1099,19 @@ export async function writeLogFile(
|
|
|
1082
1099
|
// ── tools ────────────────────────────────────────────────────────────────────
|
|
1083
1100
|
|
|
1084
1101
|
export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
1102
|
+
// Fail fast: the `gh` CLI is the only backend for these tools. Without it the
|
|
1103
|
+
// extension registers nothing and reports the problem at session start, so
|
|
1104
|
+
// the user gets one clear error instead of a dozen failing tool calls.
|
|
1105
|
+
if (!isGhAvailable()) {
|
|
1106
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1107
|
+
ctx.ui.notify(
|
|
1108
|
+
"gh CLI not found in PATH: GitHub read-only tools are disabled. Install GitHub CLI (https://cli.github.com/) and reload the session.",
|
|
1109
|
+
"error",
|
|
1110
|
+
);
|
|
1111
|
+
});
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1085
1115
|
// ── read-github-issue ──────────────────────────────────────────────────────
|
|
1086
1116
|
pi.registerTool({
|
|
1087
1117
|
name: "read-github-issue",
|