@maxiaochao/pi-toolkit 0.9.2 → 0.9.4
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/CHANGELOG.md +12 -0
- package/README.md +40 -90
- package/extensions/{btw.ts → btw/index.ts} +1 -1
- package/extensions/{tests/btw-history.mjs → btw/tests/history.mjs} +1 -1
- package/global/AGENTS.md +24 -49
- package/package.json +19 -10
- package/packages/cache-export/CHANGELOG.md +5 -0
- package/packages/cache-export/README.md +70 -0
- package/packages/cache-export/TESTING.md +17 -0
- package/packages/cache-export/docs/demo.gif +0 -0
- package/packages/cache-export/docs/screenshot.png +0 -0
- package/{extensions/cache-export/index.ts → packages/cache-export/extensions/cache-export.ts} +1 -1
- package/packages/cache-export/package.json +22 -0
- package/{extensions/cache-export → packages/cache-export/src}/render.ts +1 -1
- package/{extensions → packages}/cache-export/tests/e2e-browser.mjs +2 -2
- package/{extensions → packages}/cache-export/tests/run-tests.mjs +2 -2
- package/packages/codex-edit/CHANGELOG.md +32 -0
- package/packages/codex-edit/README.md +123 -0
- package/packages/codex-edit/TESTING.md +20 -0
- package/{extensions → packages}/codex-edit/codex-edit-explainer.html +41 -20
- package/packages/codex-edit/docs/demo.gif +0 -0
- package/packages/codex-edit/docs/screenshot.png +0 -0
- package/{extensions/codex-edit/index.ts → packages/codex-edit/extensions/codex-edit.ts} +3 -3
- package/packages/codex-edit/package.json +48 -0
- package/{extensions/codex-edit → packages/codex-edit/src}/parser.ts +1 -2
- package/{extensions → packages}/codex-edit/summary.md +1 -1
- package/{extensions → packages}/codex-edit/tests/package.test.mjs +12 -7
- package/{extensions → packages}/codex-edit/tests/parser.test.mjs +100 -3
- package/packages/tiny-subagent/CHANGELOG.md +12 -0
- package/packages/tiny-subagent/README.md +128 -0
- package/packages/tiny-subagent/TESTING.md +20 -0
- package/packages/tiny-subagent/agents/worker.md +9 -0
- package/packages/tiny-subagent/docs/demo.gif +0 -0
- package/packages/tiny-subagent/docs/screenshot.png +0 -0
- package/packages/tiny-subagent/extensions/tiny-subagent.ts +228 -0
- package/packages/tiny-subagent/package.json +44 -0
- package/packages/tiny-subagent/src/personas.ts +110 -0
- package/packages/tiny-subagent/src/runner.ts +319 -0
- package/packages/tiny-subagent/tests/run-tests.mjs +111 -0
- package/packages/todo/CHANGELOG.md +5 -0
- package/packages/todo/README.md +63 -0
- package/packages/todo/TESTING.md +20 -0
- package/packages/todo/docs/demo.gif +0 -0
- package/packages/todo/docs/screenshot.png +0 -0
- package/{extensions → packages/todo/extensions}/todo.ts +1 -1
- package/packages/todo/package.json +25 -0
- package/{extensions → packages/todo}/tests/todo-state.mjs +1 -1
- package/packages/worktree/CHANGELOG.md +5 -0
- package/packages/worktree/README.md +60 -0
- package/packages/worktree/TESTING.md +21 -0
- package/packages/worktree/docs/demo.gif +0 -0
- package/packages/worktree/docs/screenshot.png +0 -0
- package/packages/worktree/package.json +20 -0
- package/packages/worktree/scripts/install.mjs +26 -0
- package/packages/worktree/tests/install.test.mjs +25 -0
- package/packages/worktree/tests/run-tests.sh +46 -0
- package/scripts/install-agents.mjs +8 -18
- package/extensions/codex-edit/README.md +0 -53
- /package/extensions/{btw-scroll.ts → btw/scroll.ts} +0 -0
- /package/{extensions/cache-export → packages/cache-export/src}/tau-assets.ts +0 -0
- /package/{extensions → packages}/codex-edit/config.json +0 -0
- /package/{extensions/codex-edit → packages/codex-edit/src}/model-routing.ts +0 -0
- /package/{extensions → packages/todo/src}/todo-state.ts +0 -0
- /package/{bin → packages/worktree/bin}/pi-worktree +0 -0
- /package/{bin → packages/worktree/bin}/pi-worktree-completion.bash +0 -0
- /package/{skills → packages/worktree/skills}/pi-worktree/SKILL.md +0 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { createWriteStream, existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
export const MAX_INLINE_BYTES = 16 * 1024;
|
|
8
|
+
const CHILD_ENV = "PI_SUBAGENT_CHILD";
|
|
9
|
+
const BINARY_ENV = "PI_TINY_SUBAGENT_PI_BINARY";
|
|
10
|
+
|
|
11
|
+
export interface TinySubagentOptions {
|
|
12
|
+
model?: string;
|
|
13
|
+
thinking?: string;
|
|
14
|
+
tools?: string[];
|
|
15
|
+
systemPrompt?: string;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
onUpdate?: (result: TinySubagentResult) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TinySubagentArtifacts {
|
|
22
|
+
directory: string;
|
|
23
|
+
stdout: string;
|
|
24
|
+
stderr: string;
|
|
25
|
+
finalOutput?: string;
|
|
26
|
+
cleanup: "remove directory when no longer needed";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TinySubagentContent {
|
|
30
|
+
type?: string;
|
|
31
|
+
text?: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
arguments?: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TinySubagentMessage {
|
|
37
|
+
role?: string;
|
|
38
|
+
content?: string | TinySubagentContent[];
|
|
39
|
+
toolCallId?: string;
|
|
40
|
+
toolName?: string;
|
|
41
|
+
isError?: boolean;
|
|
42
|
+
stopReason?: string;
|
|
43
|
+
errorMessage?: string;
|
|
44
|
+
model?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TinySubagentResult {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
output: string;
|
|
50
|
+
outputBytes: number;
|
|
51
|
+
stderr: string;
|
|
52
|
+
stderrBytes: number;
|
|
53
|
+
stdout: string;
|
|
54
|
+
stdoutBytes: number;
|
|
55
|
+
protocolErrors: string[];
|
|
56
|
+
errorMessage?: string;
|
|
57
|
+
stopReason?: string;
|
|
58
|
+
exitCode: number | null;
|
|
59
|
+
signal: NodeJS.Signals | null;
|
|
60
|
+
model?: string;
|
|
61
|
+
cwd: string;
|
|
62
|
+
durationMs: number;
|
|
63
|
+
messages: TinySubagentMessage[];
|
|
64
|
+
artifacts?: TinySubagentArtifacts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface JsonEvent {
|
|
68
|
+
type?: string;
|
|
69
|
+
message?: TinySubagentMessage;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class Capture {
|
|
73
|
+
private readonly stream;
|
|
74
|
+
private inline = "";
|
|
75
|
+
private truncated = false;
|
|
76
|
+
bytes = 0;
|
|
77
|
+
|
|
78
|
+
readonly path: string;
|
|
79
|
+
constructor(path: string) {
|
|
80
|
+
this.path = path;
|
|
81
|
+
this.stream = createWriteStream(path, { encoding: "utf8", mode: 0o600 });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
write(text: string): void {
|
|
86
|
+
this.bytes += Buffer.byteLength(text, "utf8");
|
|
87
|
+
this.stream.write(text);
|
|
88
|
+
if (this.truncated) return;
|
|
89
|
+
const next = this.inline + text;
|
|
90
|
+
if (Buffer.byteLength(next, "utf8") <= MAX_INLINE_BYTES) {
|
|
91
|
+
this.inline = next;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.inline = truncateUtf8(next, MAX_INLINE_BYTES);
|
|
95
|
+
this.truncated = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async close(): Promise<void> {
|
|
99
|
+
await new Promise<void>((resolve, reject) => {
|
|
100
|
+
this.stream.once("error", reject);
|
|
101
|
+
this.stream.once("finish", resolve);
|
|
102
|
+
this.stream.end();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
preview(): string {
|
|
107
|
+
return this.truncated ? `${this.inline}\n[truncated after ${MAX_INLINE_BYTES} bytes; see artifact file]` : this.inline;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
112
|
+
let result = text.slice(0, maxBytes);
|
|
113
|
+
while (Buffer.byteLength(result, "utf8") > maxBytes) result = result.slice(0, -1);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getFinalText(messages: TinySubagentMessage[]): string {
|
|
118
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
119
|
+
const message = messages[i];
|
|
120
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
121
|
+
return message.content
|
|
122
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
123
|
+
.map((part) => part.text as string)
|
|
124
|
+
.join("");
|
|
125
|
+
}
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseLine(line: string, messages: TinySubagentMessage[], protocolErrors: string[]): boolean {
|
|
130
|
+
if (!line.trim()) return false;
|
|
131
|
+
try {
|
|
132
|
+
const event = JSON.parse(line) as JsonEvent;
|
|
133
|
+
if ((event.type === "message_end" || event.type === "tool_result_end") && event.message) {
|
|
134
|
+
messages.push(event.message);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
if (protocolErrors.length < 20) protocolErrors.push(truncateUtf8(line, 4096));
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
144
|
+
const configuredBinary = process.env[BINARY_ENV]?.trim();
|
|
145
|
+
if (configuredBinary) return { command: configuredBinary, args };
|
|
146
|
+
|
|
147
|
+
const entry = process.argv[1];
|
|
148
|
+
if (entry && existsSync(entry) && /\.(?:mjs|cjs|js)$/i.test(entry)) {
|
|
149
|
+
try {
|
|
150
|
+
const resolved = realpathSync(entry);
|
|
151
|
+
if (resolved === entry || existsSync(resolved)) return { command: process.execPath, args: [entry, ...args] };
|
|
152
|
+
} catch {
|
|
153
|
+
// Fall through to the Pi binary on PATH.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (/^pi(?:\.exe)?$/i.test(basename(process.execPath))) return { command: process.execPath, args };
|
|
158
|
+
return { command: "pi", args };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function stopReasonIsFailure(stopReason: string | undefined): boolean {
|
|
162
|
+
return stopReason === "error" || stopReason === "aborted";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function runTinySubagent(prompt: string, options: TinySubagentOptions = {}): Promise<TinySubagentResult> {
|
|
166
|
+
const cwd = options.cwd ?? process.cwd();
|
|
167
|
+
const artifactDirectory = await mkdtemp(join(tmpdir(), "pi-tiny-subagent-"));
|
|
168
|
+
const stdoutCapture = new Capture(join(artifactDirectory, "stdout.jsonl"));
|
|
169
|
+
const stderrCapture = new Capture(join(artifactDirectory, "stderr.log"));
|
|
170
|
+
const args = ["--mode", "json", "--print", "--no-session", "--no-extensions", "--no-skills"];
|
|
171
|
+
if (options.model?.trim()) args.push("--model", options.model.trim());
|
|
172
|
+
if (options.thinking?.trim()) args.push("--thinking", options.thinking.trim());
|
|
173
|
+
if (options.tools?.length) args.push("--tools", options.tools.join(","));
|
|
174
|
+
if (options.systemPrompt?.trim()) {
|
|
175
|
+
const systemPromptPath = join(artifactDirectory, "system-prompt.md");
|
|
176
|
+
await writeFile(systemPromptPath, options.systemPrompt, { encoding: "utf8", mode: 0o600 });
|
|
177
|
+
args.push("--append-system-prompt", systemPromptPath);
|
|
178
|
+
}
|
|
179
|
+
args.push("--", prompt);
|
|
180
|
+
|
|
181
|
+
const startedAt = Date.now();
|
|
182
|
+
const messages: TinySubagentMessage[] = [];
|
|
183
|
+
const protocolErrors: string[] = [];
|
|
184
|
+
let stdoutBuffer = "";
|
|
185
|
+
let errorMessage: string | undefined;
|
|
186
|
+
let aborted = false;
|
|
187
|
+
let exitCode: number | null = null;
|
|
188
|
+
let childSignal: NodeJS.Signals | null = null;
|
|
189
|
+
let spawnError: Error | undefined;
|
|
190
|
+
let child: ReturnType<typeof spawn> | undefined;
|
|
191
|
+
const invocation = getPiInvocation(args);
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
const emitUpdate = () => {
|
|
195
|
+
if (!options.onUpdate) return;
|
|
196
|
+
const output = getFinalText(messages);
|
|
197
|
+
const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant");
|
|
198
|
+
options.onUpdate({
|
|
199
|
+
ok: false,
|
|
200
|
+
output,
|
|
201
|
+
outputBytes: Buffer.byteLength(output, "utf8"),
|
|
202
|
+
stderr: stderrCapture.preview(),
|
|
203
|
+
stderrBytes: stderrCapture.bytes,
|
|
204
|
+
stdout: stdoutCapture.preview(),
|
|
205
|
+
stdoutBytes: stdoutCapture.bytes,
|
|
206
|
+
protocolErrors: [...protocolErrors],
|
|
207
|
+
...(lastAssistant?.stopReason ? { stopReason: lastAssistant.stopReason } : {}),
|
|
208
|
+
exitCode,
|
|
209
|
+
signal: childSignal,
|
|
210
|
+
...(options.model ?? lastAssistant?.model ? { model: options.model ?? lastAssistant?.model } : {}),
|
|
211
|
+
cwd,
|
|
212
|
+
durationMs: Date.now() - startedAt,
|
|
213
|
+
messages: messages.slice(),
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
emitUpdate();
|
|
218
|
+
await new Promise<void>((resolve) => {
|
|
219
|
+
let settled = false;
|
|
220
|
+
const finish = () => {
|
|
221
|
+
if (settled) return;
|
|
222
|
+
settled = true;
|
|
223
|
+
resolve();
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
child = spawn(invocation.command, invocation.args, {
|
|
227
|
+
cwd,
|
|
228
|
+
shell: false,
|
|
229
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
230
|
+
env: { ...process.env, [CHILD_ENV]: "1" },
|
|
231
|
+
});
|
|
232
|
+
} catch (error) {
|
|
233
|
+
spawnError = error instanceof Error ? error : new Error(String(error));
|
|
234
|
+
finish();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
238
|
+
const text = chunk.toString();
|
|
239
|
+
stdoutCapture.write(text);
|
|
240
|
+
stdoutBuffer += text;
|
|
241
|
+
const lines = stdoutBuffer.split("\n");
|
|
242
|
+
stdoutBuffer = lines.pop() ?? "";
|
|
243
|
+
for (const line of lines) {
|
|
244
|
+
if (parseLine(line, messages, protocolErrors)) emitUpdate();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
child.stderr?.on("data", (chunk: Buffer | string) => stderrCapture.write(chunk.toString()));
|
|
248
|
+
child.once("error", (error) => {
|
|
249
|
+
spawnError = error;
|
|
250
|
+
});
|
|
251
|
+
child.once("close", (code, signal) => {
|
|
252
|
+
if (stdoutBuffer && parseLine(stdoutBuffer, messages, protocolErrors)) emitUpdate();
|
|
253
|
+
// Node may report a negative pseudo-exit code for ENOENT; expose a stable process failure code and keep the real error in errorMessage.
|
|
254
|
+
exitCode = spawnError ? 1 : code;
|
|
255
|
+
childSignal = signal;
|
|
256
|
+
finish();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const terminate = () => {
|
|
260
|
+
if (aborted) return;
|
|
261
|
+
aborted = true;
|
|
262
|
+
child?.kill("SIGTERM");
|
|
263
|
+
setTimeout(() => {
|
|
264
|
+
if (child && !child.killed) child.kill("SIGKILL");
|
|
265
|
+
}, 5000).unref();
|
|
266
|
+
};
|
|
267
|
+
if (options.signal) {
|
|
268
|
+
if (options.signal.aborted) terminate();
|
|
269
|
+
else options.signal.addEventListener("abort", terminate, { once: true });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await Promise.all([stdoutCapture.close(), stderrCapture.close()]);
|
|
274
|
+
const finalOutput = getFinalText(messages);
|
|
275
|
+
const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant");
|
|
276
|
+
const stopReason = lastAssistant?.stopReason;
|
|
277
|
+
errorMessage = spawnError?.message ?? lastAssistant?.errorMessage;
|
|
278
|
+
if (aborted) errorMessage ||= "Tiny subagent was aborted";
|
|
279
|
+
if (!finalOutput && exitCode === 0 && !stopReason) errorMessage ||= "No assistant output received";
|
|
280
|
+
if (protocolErrors.length > 0 && !finalOutput && !errorMessage) errorMessage = "Pi emitted invalid JSON output";
|
|
281
|
+
if (stopReasonIsFailure(stopReason)) errorMessage ||= `Subagent stopped with reason: ${stopReason}`;
|
|
282
|
+
|
|
283
|
+
const outputBytes = Buffer.byteLength(finalOutput, "utf8");
|
|
284
|
+
const keepArtifacts = stdoutCapture.bytes > MAX_INLINE_BYTES || stderrCapture.bytes > MAX_INLINE_BYTES || outputBytes > MAX_INLINE_BYTES;
|
|
285
|
+
let artifacts: TinySubagentArtifacts | undefined;
|
|
286
|
+
if (keepArtifacts) {
|
|
287
|
+
const finalOutputPath = join(artifactDirectory, "final-output.txt");
|
|
288
|
+
if (outputBytes > 0) await writeFile(finalOutputPath, finalOutput, { encoding: "utf8", mode: 0o600 });
|
|
289
|
+
artifacts = {
|
|
290
|
+
directory: artifactDirectory,
|
|
291
|
+
stdout: stdoutCapture.path,
|
|
292
|
+
stderr: stderrCapture.path,
|
|
293
|
+
cleanup: "remove directory when no longer needed",
|
|
294
|
+
...(outputBytes > MAX_INLINE_BYTES ? { finalOutput: finalOutputPath } : {}),
|
|
295
|
+
};
|
|
296
|
+
} else {
|
|
297
|
+
await rm(artifactDirectory, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
ok: !errorMessage && exitCode === 0 && childSignal === null && !stopReasonIsFailure(stopReason),
|
|
302
|
+
output: outputBytes > MAX_INLINE_BYTES ? `${truncateUtf8(finalOutput, MAX_INLINE_BYTES)}\n[truncated after ${MAX_INLINE_BYTES} bytes; see artifact file]` : finalOutput,
|
|
303
|
+
outputBytes,
|
|
304
|
+
stderr: stderrCapture.preview(),
|
|
305
|
+
stderrBytes: stderrCapture.bytes,
|
|
306
|
+
stdout: stdoutCapture.preview(),
|
|
307
|
+
stdoutBytes: stdoutCapture.bytes,
|
|
308
|
+
protocolErrors,
|
|
309
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
310
|
+
...(stopReason ? { stopReason } : {}),
|
|
311
|
+
exitCode,
|
|
312
|
+
signal: childSignal,
|
|
313
|
+
...(options.model ?? lastAssistant?.model ? { model: options.model ?? lastAssistant?.model } : {}),
|
|
314
|
+
cwd,
|
|
315
|
+
durationMs: Date.now() - startedAt,
|
|
316
|
+
messages: messages.slice(),
|
|
317
|
+
...(artifacts ? { artifacts } : {}),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { discoverPackageAgents, parseAgentMarkdown } from "../src/personas.ts";
|
|
5
|
+
import { MAX_INLINE_BYTES, runTinySubagent } from "../src/runner.ts";
|
|
6
|
+
|
|
7
|
+
let passed = 0;
|
|
8
|
+
let failed = 0;
|
|
9
|
+
function check(name, condition, detail = "") {
|
|
10
|
+
if (condition) {
|
|
11
|
+
passed += 1;
|
|
12
|
+
console.log(`PASS ${name}`);
|
|
13
|
+
} else {
|
|
14
|
+
failed += 1;
|
|
15
|
+
console.log(`FAIL ${name} ${detail}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), "pi-tiny-subagent-test-"));
|
|
20
|
+
const fakePi = join(root, "fake-pi.mjs");
|
|
21
|
+
writeFileSync(fakePi, `#!/usr/bin/env node
|
|
22
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const capture = process.env.TINY_CAPTURE;
|
|
25
|
+
if (capture) appendFileSync(capture, JSON.stringify({ args, cwd: process.cwd(), child: process.env.PI_SUBAGENT_CHILD, promptFile: args[args.indexOf("--append-system-prompt") + 1] ? readFileSync(args[args.indexOf("--append-system-prompt") + 1], "utf8") : null }) + "\\n");
|
|
26
|
+
const mode = process.env.TINY_MODE || "success";
|
|
27
|
+
if (mode === "abort") { setTimeout(() => {}, 30000); }
|
|
28
|
+
if (mode === "success") {
|
|
29
|
+
process.stdout.write(JSON.stringify({ type: "message_end", message: { role: "assistant", model: "fake/model", stopReason: "stop", content: [{ type: "text", text: "worker result" }] } }) + "\\n");
|
|
30
|
+
} else if (mode === "events") {
|
|
31
|
+
process.stdout.write(JSON.stringify({ type: "message_end", message: { role: "assistant", content: [{ type: "toolCall", name: "bash", arguments: { command: "printf live" } }] } }) + "\\n");
|
|
32
|
+
process.stdout.write(JSON.stringify({ type: "tool_result_end", message: { role: "toolResult", toolName: "bash", toolCallId: "call-1", isError: false, content: [{ type: "text", text: "live result" }] } }) + "\\n");
|
|
33
|
+
process.stdout.write(JSON.stringify({ type: "message_end", message: { role: "assistant", model: "fake/model", stopReason: "stop", content: [{ type: "text", text: "event result" }] } }) + "\\n");
|
|
34
|
+
} else if (mode === "error") {
|
|
35
|
+
process.stderr.write("provider failed\\n");
|
|
36
|
+
process.stdout.write(JSON.stringify({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "rate limited", content: [] } }) + "\\n");
|
|
37
|
+
process.exitCode = 7;
|
|
38
|
+
} else if (mode === "protocol") {
|
|
39
|
+
process.stdout.write("not-json\\n");
|
|
40
|
+
process.exitCode = 2;
|
|
41
|
+
} else if (mode === "large") {
|
|
42
|
+
const text = "x".repeat(${MAX_INLINE_BYTES + 1000});
|
|
43
|
+
process.stderr.write("e".repeat(${MAX_INLINE_BYTES + 1000}));
|
|
44
|
+
process.stdout.write(JSON.stringify({ type: "message_end", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text }] } }) + "\\n");
|
|
45
|
+
}
|
|
46
|
+
`, { encoding: "utf8", mode: 0o700 });
|
|
47
|
+
chmodSync(fakePi, 0o700);
|
|
48
|
+
|
|
49
|
+
const capture = join(root, "capture.jsonl");
|
|
50
|
+
const oldEnv = { ...process.env };
|
|
51
|
+
function setEnv(values) {
|
|
52
|
+
for (const key of Object.keys(process.env)) delete process.env[key];
|
|
53
|
+
Object.assign(process.env, oldEnv, values);
|
|
54
|
+
}
|
|
55
|
+
function restoreEnv() {
|
|
56
|
+
for (const key of Object.keys(process.env)) delete process.env[key];
|
|
57
|
+
Object.assign(process.env, oldEnv);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
setEnv({ PI_TINY_SUBAGENT_PI_BINARY: fakePi, TINY_CAPTURE: capture, TINY_MODE: "success" });
|
|
62
|
+
const success = await runTinySubagent("hello", { cwd: root, model: "fake/model", tools: ["read"], systemPrompt: "You are a worker." });
|
|
63
|
+
const launch = JSON.parse(readFileSync(capture, "utf8").trim());
|
|
64
|
+
check("success output", success.ok && success.output === "worker result");
|
|
65
|
+
check("model, tools, and fresh-process flags", launch.args.includes("--model") && launch.args.includes("fake/model") && launch.args.includes("--tools") && launch.args.includes("read") && launch.args.includes("--no-session") && launch.args.includes("--no-extensions") && launch.args.includes("--no-skills"));
|
|
66
|
+
check("cwd and child marker", launch.cwd === root && launch.child === "1");
|
|
67
|
+
check("persona system prompt file", launch.promptFile === "You are a worker.");
|
|
68
|
+
|
|
69
|
+
setEnv({ PI_TINY_SUBAGENT_PI_BINARY: fakePi, TINY_MODE: "events" });
|
|
70
|
+
const updates = [];
|
|
71
|
+
const events = await runTinySubagent("events", { cwd: root, onUpdate: (partial) => updates.push(partial) });
|
|
72
|
+
check("event transcript and live updates", events.ok && events.output === "event result" && events.messages.length === 3 && events.messages[0].content?.[0]?.type === "toolCall" && events.messages[1].role === "toolResult" && updates.length >= 4 && updates.at(-1).messages.length === 3);
|
|
73
|
+
|
|
74
|
+
setEnv({ PI_TINY_SUBAGENT_PI_BINARY: fakePi, TINY_MODE: "error" });
|
|
75
|
+
const error = await runTinySubagent("fail", { cwd: root });
|
|
76
|
+
check("Pi error details", !error.ok && error.exitCode === 7 && error.stopReason === "error" && error.errorMessage === "rate limited" && error.stderr.includes("provider failed"));
|
|
77
|
+
setEnv({ PI_TINY_SUBAGENT_PI_BINARY: fakePi, TINY_MODE: "protocol" });
|
|
78
|
+
const protocol = await runTinySubagent("bad", { cwd: root });
|
|
79
|
+
check("protocol error details", !protocol.ok && protocol.exitCode === 2 && protocol.protocolErrors.includes("not-json"));
|
|
80
|
+
|
|
81
|
+
setEnv({ PI_TINY_SUBAGENT_PI_BINARY: "/definitely/missing/pi", TINY_MODE: "success" });
|
|
82
|
+
const missing = await runTinySubagent("missing", { cwd: root });
|
|
83
|
+
check("spawn error details", !missing.ok && missing.exitCode === 1 && missing.errorMessage?.includes("ENOENT"));
|
|
84
|
+
|
|
85
|
+
setEnv({ PI_TINY_SUBAGENT_PI_BINARY: fakePi, TINY_MODE: "large" });
|
|
86
|
+
const large = await runTinySubagent("large", { cwd: root });
|
|
87
|
+
check("large output spills to artifacts", large.ok && large.artifacts && large.artifacts.cleanup.includes("remove directory") && large.output.includes("see artifact file") && large.outputBytes > MAX_INLINE_BYTES && large.artifacts.finalOutput && readFileSync(large.artifacts.finalOutput, "utf8").length > MAX_INLINE_BYTES);
|
|
88
|
+
if (large.artifacts) rmSync(large.artifacts.directory, { recursive: true, force: true });
|
|
89
|
+
|
|
90
|
+
setEnv({ PI_TINY_SUBAGENT_PI_BINARY: fakePi, TINY_MODE: "abort" });
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const pending = runTinySubagent("abort", { cwd: root, signal: controller.signal });
|
|
93
|
+
setTimeout(() => controller.abort(), 50);
|
|
94
|
+
const aborted = await pending;
|
|
95
|
+
check("abort details", !aborted.ok && aborted.errorMessage === "Tiny subagent was aborted");
|
|
96
|
+
|
|
97
|
+
const definition = parseAgentMarkdown(`---\nname: reviewer\ndescription: Review code\nmodel: fake/model\ntools: read, grep\n---\nReview carefully.`, "reviewer.md");
|
|
98
|
+
check("persona frontmatter parsing", definition.name === "reviewer" && definition.model === "fake/model" && definition.tools?.join(",") === "read,grep" && definition.systemPrompt === "Review carefully.");
|
|
99
|
+
const agentsDir = join(root, "agents");
|
|
100
|
+
await import("node:fs/promises").then(({ mkdir }) => mkdir(agentsDir));
|
|
101
|
+
writeFileSync(join(agentsDir, "worker.md"), `---\nname: worker\ndescription: Worker\n---\nDo work.`);
|
|
102
|
+
writeFileSync(join(agentsDir, "broken.md"), "not frontmatter");
|
|
103
|
+
const discovery = discoverPackageAgents(agentsDir);
|
|
104
|
+
check("persona discovery and diagnostics", discovery.agents.length === 1 && discovery.agents[0].name === "worker" && discovery.diagnostics.length === 1);
|
|
105
|
+
} finally {
|
|
106
|
+
restoreEnv();
|
|
107
|
+
rmSync(root, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log(`${passed} passed, ${failed} failed`);
|
|
111
|
+
if (failed > 0) process.exitCode = 1;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @maxiaochao/pi-todo
|
|
2
|
+
|
|
3
|
+
Pi 的会话清单扩展,提供 `todo` 工具、自动推进的生命周期、活跃任务 widget 和 `/todos` 历史视图。
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## 为什么有这个包
|
|
8
|
+
|
|
9
|
+
这是 pi-toolkit 自研的计划执行模块,用明确状态替代只存在于对话文本里的计划。它解决计划替换后旧任务残留、多个任务同时 doing、完成后不自动推进,以及 UI 序号与持久 ID 混用的问题。
|
|
10
|
+
|
|
11
|
+
## 演示
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
todo({ action: "replace", items: ["Inspect callers", "Implement", "Verify"] })
|
|
17
|
+
todo({ action: "update", id: 1, status: "done" })
|
|
18
|
+
/todos
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
第一项完成后下一项自动进入 `doing`;`/todos` 同时展示活跃队列和关闭历史。
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
完整工具包已经包含本扩展:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pi install npm:@maxiaochao/pi-toolkit
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
只需要 todo 时单独安装:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi install npm:@maxiaochao/pi-todo
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
两种方式二选一,避免重复注册。
|
|
38
|
+
|
|
39
|
+
## 更新
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pi update npm:@maxiaochao/pi-toolkit # 更新主包内置版本
|
|
43
|
+
pi update npm:@maxiaochao/pi-todo # 更新独立子包
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
两个 npm 包版本独立。Todo 源码变更只有在发布新主包后才会到达主包用户,发布子包本身不会更新主包。
|
|
47
|
+
|
|
48
|
+
## 行为
|
|
49
|
+
|
|
50
|
+
- `replace` 用新计划替换旧计划,并将未完成旧任务标记为取消。
|
|
51
|
+
- `add`、`update`、`remove`、`clear` 管理任务。
|
|
52
|
+
- 同一时间最多一个任务为 `doing`,完成后自动推进下一项。
|
|
53
|
+
- widget 使用连续队列位置;持久记录保留稳定内部 ID。
|
|
54
|
+
|
|
55
|
+
## 开发与测试
|
|
56
|
+
|
|
57
|
+
权威源码位于 `extensions/` 和 `src/`。测试流程、用例和最近验证结果见 [TESTING.md](TESTING.md)。
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm --prefix packages/todo test
|
|
61
|
+
npm --prefix packages/todo run load-test
|
|
62
|
+
npm pack ./packages/todo --dry-run
|
|
63
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Todo 测试方案
|
|
2
|
+
|
|
3
|
+
## 流程
|
|
4
|
+
|
|
5
|
+
1. `npm --prefix packages/todo test`:运行纯状态机确定性测试。
|
|
6
|
+
2. `npm --prefix packages/todo run load-test`:让 Pi 加载真实扩展入口。
|
|
7
|
+
3. `npm pack ./packages/todo --dry-run`:确认发布文件完整。
|
|
8
|
+
4. 确认由真实状态机输出生成的 `docs/screenshot.png` 和 `docs/demo.gif` 可打开且进入 tarball。
|
|
9
|
+
|
|
10
|
+
## 用例数据
|
|
11
|
+
|
|
12
|
+
- 恢复旧格式记录并修复非法生命周期。
|
|
13
|
+
- `replace` 取消旧的活跃任务并建立新队列。
|
|
14
|
+
- 完成当前任务后自动推进下一项。
|
|
15
|
+
- 删除、清空、非法 ID 和非法状态处理。
|
|
16
|
+
- widget 队列位置连续,内部 ID 保持稳定。
|
|
17
|
+
|
|
18
|
+
## 最近验证结果
|
|
19
|
+
|
|
20
|
+
迁移基线:状态机测试全部通过,扩展可由 Pi 独立加载。发布前必须重新运行上述三步;跳过的检查不算通过。
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maxiaochao/pi-todo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Session checklist and todo lifecycle extension for Pi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": ["pi-package", "pi-extension", "todo"],
|
|
7
|
+
"files": ["extensions", "src", "docs", "README.md", "TESTING.md", "CHANGELOG.md"],
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/chaochaoxiaochao/pi-toolkit.git",
|
|
11
|
+
"directory": "packages/todo"
|
|
12
|
+
},
|
|
13
|
+
"pi": { "extensions": ["./extensions/todo.ts"] },
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@earendil-works/pi-ai": "*",
|
|
16
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
17
|
+
"@earendil-works/pi-tui": "*",
|
|
18
|
+
"typebox": "*"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --experimental-strip-types tests/todo-state.mjs",
|
|
22
|
+
"load-test": "pi -ne -e ./extensions/todo.ts"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": { "access": "public" }
|
|
25
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @maxiaochao/pi-worktree
|
|
2
|
+
|
|
3
|
+
`pi-worktree` CLI 与配套 Pi skill。CLI 创建、检查和清理 Git worktree,并在隔离目录中启动 Pi。
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## 为什么有这个包
|
|
8
|
+
|
|
9
|
+
这个包把原来分散在主包中的 shell CLI、bash completion、安装逻辑和 agent skill 合成一个发布单元。它让人和 agent 使用同一套隔离流程,避免在主 checkout 中混入并行任务改动。
|
|
10
|
+
|
|
11
|
+
## 演示
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi-worktree start feature -p "implement the feature"
|
|
17
|
+
pi-worktree info feature
|
|
18
|
+
pi-worktree out
|
|
19
|
+
pi-worktree remove feature
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`start` 从当前分支创建或复用 `.worktrees/feature`,记录 base,并在该目录启动 Pi。
|
|
23
|
+
|
|
24
|
+
## 安装
|
|
25
|
+
|
|
26
|
+
完整工具包已经包含 CLI 和 skill:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi install npm:@maxiaochao/pi-toolkit
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
只需要 worktree 能力时单独安装:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install npm:@maxiaochao/pi-worktree
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
postinstall 会把 CLI 复制到 `~/.local/bin/pi-worktree`,把 bash completion 安装到 `~/.local/share/bash-completion/completions/pi-worktree`。两种安装方式二选一。
|
|
39
|
+
|
|
40
|
+
## 更新
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi update npm:@maxiaochao/pi-toolkit # 更新主包内置的 CLI 与 skill
|
|
44
|
+
pi update npm:@maxiaochao/pi-worktree # 更新独立子包
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
更新会重新运行安装逻辑并覆盖 `~/.local/bin/pi-worktree` 和 completion。主包与子包版本独立,不要同时安装。
|
|
48
|
+
|
|
49
|
+
## 命令
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pi-worktree start <name> [pi args...]
|
|
53
|
+
pi-worktree list
|
|
54
|
+
pi-worktree info <name>
|
|
55
|
+
pi-worktree out
|
|
56
|
+
pi-worktree remove <name>
|
|
57
|
+
pi-worktree prune
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Agent 用法见 `skills/pi-worktree/SKILL.md`。测试流程、夹具和结果见 [TESTING.md](TESTING.md)。
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Worktree 测试方案
|
|
2
|
+
|
|
3
|
+
## 流程
|
|
4
|
+
|
|
5
|
+
1. `npm --prefix packages/worktree test`:在临时 Git 仓库中使用假的 `pi` 命令运行 CLI。
|
|
6
|
+
2. `npm pack ./packages/worktree --dry-run`:确认 CLI、completion、skill 和安装脚本进入 tarball。
|
|
7
|
+
3. 手工验收发布包的 `pi-worktree start` 交互流程。
|
|
8
|
+
4. 确认 README 示例、真实临时 Git 仓库输出 `docs/screenshot.png` 和 `docs/demo.gif` 进入 tarball。
|
|
9
|
+
|
|
10
|
+
## 用例数据
|
|
11
|
+
|
|
12
|
+
- `--help` 输出命令说明。
|
|
13
|
+
- `start` 从当前分支创建 `.worktrees/<name>` 并记录 base。
|
|
14
|
+
- 假 Pi 收到正确工作目录和参数。
|
|
15
|
+
- `list` 标记主 checkout 和子 worktree。
|
|
16
|
+
- `info` 报告 clean、branch、base 和 ahead。
|
|
17
|
+
- postinstall 把可执行 CLI 与 completion 复制到隔离 HOME,并保持执行权限。
|
|
18
|
+
|
|
19
|
+
## 最近验证结果
|
|
20
|
+
|
|
21
|
+
迁移基线要求全部 shell 断言通过,且 `npm pack --dry-run` 包含 `bin/`、`skills/` 和 `scripts/install.mjs`。
|
|
Binary file
|
|
Binary file
|