@maxiaochao/pi-toolkit 0.9.4 → 0.10.0

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 (80) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/package.json +5 -5
  4. package/packages/codex-edit/CHANGELOG.md +15 -0
  5. package/packages/codex-edit/README.md +10 -3
  6. package/packages/codex-edit/TESTING.md +40 -9
  7. package/packages/codex-edit/benchmark/explicit-edit-100.json +2824 -0
  8. package/packages/codex-edit/benchmark/export-evidence.mjs +77 -0
  9. package/packages/codex-edit/benchmark/incident-cases.mjs +37 -0
  10. package/packages/codex-edit/benchmark/prepare.mjs +181 -0
  11. package/packages/codex-edit/benchmark/results/explicit-edit-100-sol-low-v3.json +4562 -0
  12. package/packages/codex-edit/benchmark/run-with-budget.mjs +77 -0
  13. package/packages/codex-edit/benchmark/schedule.mjs +19 -0
  14. package/packages/codex-edit/benchmark/select-tasks.mjs +167 -0
  15. package/packages/codex-edit/codex-edit-explainer.html +4 -4
  16. package/packages/codex-edit/docs/test-report.html +181 -0
  17. package/packages/codex-edit/docs/test-report.md +242 -0
  18. package/packages/codex-edit/docs/test-summary.md +9 -0
  19. package/packages/codex-edit/extensions/codex-edit.ts +4 -2
  20. package/packages/codex-edit/package.json +8 -2
  21. package/packages/codex-edit/src/parser.ts +26 -3
  22. package/packages/codex-edit/tests/benchmark.test.mjs +81 -0
  23. package/packages/codex-edit/tests/comparison-cases.mjs +396 -0
  24. package/packages/codex-edit/tests/comparison-harness.mjs +44 -0
  25. package/packages/codex-edit/tests/generate-test-report.mjs +589 -0
  26. package/packages/codex-edit/tests/legacy-parser-v0.1.7.mjs +128 -0
  27. package/packages/codex-edit/tests/native-vs-codex.ts +109 -0
  28. package/packages/codex-edit/tests/package.test.mjs +8 -0
  29. package/packages/codex-edit/tests/parser.test.mjs +123 -1
  30. package/packages/codex-edit/tests/released-vs-local.test.mjs +31 -0
  31. package/packages/herdr-subagents/CHANGELOG.md +41 -0
  32. package/packages/herdr-subagents/README.md +155 -0
  33. package/packages/herdr-subagents/TESTING.md +24 -0
  34. package/packages/herdr-subagents/agents/explorer.md +8 -0
  35. package/packages/herdr-subagents/agents/reviewer.md +8 -0
  36. package/packages/{tiny-subagent → herdr-subagents}/agents/worker.md +1 -0
  37. package/packages/herdr-subagents/docs/demo.gif +0 -0
  38. package/packages/herdr-subagents/docs/screenshot.png +0 -0
  39. package/packages/herdr-subagents/extensions/herdr-subagents.ts +258 -0
  40. package/packages/herdr-subagents/extensions/subagent-report.ts +65 -0
  41. package/packages/{tiny-subagent → herdr-subagents}/package.json +7 -6
  42. package/packages/herdr-subagents/src/batch-runner.ts +219 -0
  43. package/packages/herdr-subagents/src/blocking.ts +175 -0
  44. package/packages/herdr-subagents/src/config.ts +125 -0
  45. package/packages/herdr-subagents/src/continue-run.ts +87 -0
  46. package/packages/herdr-subagents/src/dispatcher.ts +73 -0
  47. package/packages/herdr-subagents/src/errors.ts +3 -0
  48. package/packages/herdr-subagents/src/fleet-controller.ts +82 -0
  49. package/packages/herdr-subagents/src/fleet.ts +56 -0
  50. package/packages/herdr-subagents/src/herdr.ts +224 -0
  51. package/packages/herdr-subagents/src/history.ts +210 -0
  52. package/packages/herdr-subagents/src/live-agent.ts +87 -0
  53. package/packages/herdr-subagents/src/monitor.ts +18 -0
  54. package/packages/herdr-subagents/src/ownership.ts +17 -0
  55. package/packages/herdr-subagents/src/parameters.ts +21 -0
  56. package/packages/{tiny-subagent → herdr-subagents}/src/personas.ts +13 -2
  57. package/packages/herdr-subagents/src/protocol.ts +23 -0
  58. package/packages/herdr-subagents/src/queued-runs.ts +70 -0
  59. package/packages/herdr-subagents/src/reconcile.ts +167 -0
  60. package/packages/herdr-subagents/src/records.ts +213 -0
  61. package/packages/herdr-subagents/src/result-path.ts +7 -0
  62. package/packages/herdr-subagents/src/run-locator.ts +19 -0
  63. package/packages/herdr-subagents/src/run-pane-allocator.ts +78 -0
  64. package/packages/herdr-subagents/src/run-status.ts +23 -0
  65. package/packages/herdr-subagents/src/runner.ts +250 -0
  66. package/packages/herdr-subagents/src/state.ts +11 -0
  67. package/packages/herdr-subagents/src/tab-cleanup.ts +68 -0
  68. package/packages/herdr-subagents/src/tool.ts +217 -0
  69. package/packages/herdr-subagents/src/validation.ts +38 -0
  70. package/packages/herdr-subagents/tests/extension-contract.ts +225 -0
  71. package/packages/herdr-subagents/tests/run-tests.mjs +889 -0
  72. package/packages/todo/extensions/todo.ts +1 -1
  73. package/packages/tiny-subagent/CHANGELOG.md +0 -12
  74. package/packages/tiny-subagent/README.md +0 -128
  75. package/packages/tiny-subagent/TESTING.md +0 -20
  76. package/packages/tiny-subagent/docs/demo.gif +0 -0
  77. package/packages/tiny-subagent/docs/screenshot.png +0 -0
  78. package/packages/tiny-subagent/extensions/tiny-subagent.ts +0 -228
  79. package/packages/tiny-subagent/src/runner.ts +0 -319
  80. package/packages/tiny-subagent/tests/run-tests.mjs +0 -111
@@ -0,0 +1,128 @@
1
+ // Frozen semantic baseline from @maxiaochao/pi-codex-edit v0.1.7.
2
+ // Source blob: codex-edit-v0.1.7:packages/codex-edit/src/parser.ts
3
+ // Git blob ID: e2896b59d40b5f6f01b3a4a7918476f4acc28cd8
4
+ // Keep this file unchanged; add cases to comparison-cases.mjs instead.
5
+
6
+ export function parseLegacyPatch(patch) {
7
+ const lines = patch.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim().split("\n");
8
+ if (lines[0]?.trim() !== "*** Begin Patch") throw new Error("The first line of the patch must be '*** Begin Patch'");
9
+ if (lines.at(-1)?.trim() !== "*** End Patch") throw new Error("The last line of the patch must be '*** End Patch'");
10
+ const operations = [];
11
+ let index = 1;
12
+ while (index < lines.length - 1) {
13
+ const line = lines[index];
14
+ if (line.startsWith("*** Add File: ")) {
15
+ const path = line.slice("*** Add File: ".length);
16
+ index += 1;
17
+ const content = [];
18
+ while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
19
+ if (!lines[index].startsWith("+")) throw new Error(`Invalid add line ${index + 1}`);
20
+ content.push(lines[index].slice(1));
21
+ index += 1;
22
+ }
23
+ if (content.length === 0) throw new Error(`Add file hunk for path '${path}' is empty`);
24
+ operations.push({ action: "add", path, content: `${content.join("\n")}\n` });
25
+ continue;
26
+ }
27
+ if (line.startsWith("*** Delete File: ")) {
28
+ const path = line.slice("*** Delete File: ".length);
29
+ if (!path) throw new Error(`Delete file hunk on line ${index + 1} has an empty path`);
30
+ operations.push({ action: "delete", path });
31
+ index += 1;
32
+ continue;
33
+ }
34
+ if (line.startsWith("*** Update File: ")) {
35
+ const path = line.slice("*** Update File: ".length);
36
+ index += 1;
37
+ let moveTo;
38
+ if (lines[index]?.startsWith("*** Move to: ")) {
39
+ moveTo = lines[index].slice("*** Move to: ".length);
40
+ index += 1;
41
+ }
42
+ const chunks = [];
43
+ while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
44
+ let context;
45
+ if (lines[index] === "@@" || lines[index].startsWith("@@ ")) {
46
+ context = lines[index].slice(2).trim() || undefined;
47
+ index += 1;
48
+ }
49
+ const oldLines = [];
50
+ const newLines = [];
51
+ const contextLineIndices = [];
52
+ while (index < lines.length - 1 && !lines[index].startsWith("*** ") && !lines[index].startsWith("@@")) {
53
+ const change = lines[index];
54
+ const marker = change[0];
55
+ if (!["+", "-", " "].includes(marker)) throw new Error(`Invalid update line ${index + 1}`);
56
+ const text = change.slice(1);
57
+ if (marker === "+") newLines.push(text);
58
+ else if (marker === "-") oldLines.push(text);
59
+ else {
60
+ contextLineIndices.push([oldLines.length, newLines.length]);
61
+ oldLines.push(text);
62
+ newLines.push(text);
63
+ }
64
+ index += 1;
65
+ }
66
+ const endOfFile = lines[index] === "*** End of File";
67
+ if (endOfFile) index += 1;
68
+ if (oldLines.length === 0 && newLines.length === 0) throw new Error(`Empty update chunk on line ${index + 1}`);
69
+ chunks.push({ context, oldLines, newLines, contextLineIndices, endOfFile });
70
+ }
71
+ if (chunks.length === 0) throw new Error(`Update file hunk for path '${path}' is empty`);
72
+ operations.push({ action: "update", path, moveTo, chunks });
73
+ continue;
74
+ }
75
+ throw new Error(`Invalid hunk header on line ${index + 1}: ${line}`);
76
+ }
77
+ if (operations.length === 0) throw new Error("Patch contains no file operations");
78
+ const targets = operations.flatMap((operation) => [operation.path, operation.moveTo].filter(Boolean));
79
+ if (new Set(targets).size !== targets.length) throw new Error("Multiple operations target the same path");
80
+ return operations;
81
+ }
82
+
83
+ function findCandidates(lines, pattern, start, eof, compare) {
84
+ const candidates = [];
85
+ for (let index = start; index + pattern.length <= lines.length; index += 1) {
86
+ if (pattern.every((line, offset) => compare(lines[index + offset], line))) candidates.push(index);
87
+ }
88
+ candidates.sort((left, right) => eof ? right - left : left - right);
89
+ return candidates;
90
+ }
91
+
92
+ function locate(lines, pattern, start, eof, label) {
93
+ if (pattern.length === 0) return Math.min(start, lines.length);
94
+ const comparisons = [
95
+ (left, right) => left === right,
96
+ (left, right) => left.trimEnd() === right.trimEnd(),
97
+ (left, right) => left.trim() === right.trim(),
98
+ ];
99
+ for (const compare of comparisons) {
100
+ const candidates = findCandidates(lines, pattern, start, eof, compare);
101
+ if (candidates.length > 0) return candidates[0];
102
+ }
103
+ throw new Error(`Failed to find expected lines${label ? ` in ${label}` : ""}: ${pattern.join("\\n")}`);
104
+ }
105
+
106
+ export function applyLegacyUpdate(content, chunks, path) {
107
+ const bom = content.startsWith("\ufeff") ? "\ufeff" : "";
108
+ const withoutBom = bom ? content.slice(1) : content;
109
+ const ending = withoutBom.includes("\r\n") ? "\r\n" : "\n";
110
+ const normalized = withoutBom.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
111
+ const hadFinalNewline = normalized.endsWith("\n");
112
+ const lines = normalized === "" ? [] : normalized.split("\n");
113
+ if (hadFinalNewline) lines.pop();
114
+ const replacements = [];
115
+ let cursor = 0;
116
+ for (const chunk of chunks) {
117
+ if (chunk.context !== undefined) cursor = locate(lines, [chunk.context], cursor, false, path) + 1;
118
+ const start = locate(lines, chunk.oldLines, cursor, chunk.endOfFile, path);
119
+ replacements.push({ start, length: chunk.oldLines.length, next: chunk.newLines });
120
+ cursor = start + chunk.oldLines.length;
121
+ }
122
+ const output = [...lines];
123
+ for (const replacement of [...replacements].reverse()) output.splice(replacement.start, replacement.length, ...replacement.next);
124
+ let next = output.join("\n");
125
+ if (hadFinalNewline || next.length > 0) next += "\n";
126
+ if (ending === "\r\n") next = next.replace(/\n/g, "\r\n");
127
+ return bom + next;
128
+ }
@@ -0,0 +1,109 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { createEditToolDefinition } from "@earendil-works/pi-coding-agent";
6
+ import registerCodexEdit from "../extensions/codex-edit.ts";
7
+ import { comparisonCases } from "./comparison-cases.mjs";
8
+
9
+ async function writeSnapshot(root, snapshot) {
10
+ for (const [path, content] of Object.entries(snapshot)) {
11
+ const absolute = join(root, path);
12
+ await mkdir(dirname(absolute), { recursive: true });
13
+ await writeFile(absolute, content, "utf8");
14
+ }
15
+ }
16
+
17
+ async function readSnapshot(root, relative = "") {
18
+ const snapshot = {};
19
+ for (const entry of await readdir(join(root, relative), { withFileTypes: true })) {
20
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
21
+ if (entry.isDirectory()) Object.assign(snapshot, await readSnapshot(root, path));
22
+ else snapshot[path] = await readFile(join(root, path), "utf8");
23
+ }
24
+ return snapshot;
25
+ }
26
+
27
+ async function runNativeCase(testCase) {
28
+ if (testCase.outcomes.native === "unsupported") {
29
+ assert.equal(testCase.nativeCalls, null, `${testCase.id}: unsupported native case must not invent edit calls`);
30
+ return "unsupported";
31
+ }
32
+ const workspace = await mkdtemp(join(tmpdir(), "codex-edit-native-"));
33
+ try {
34
+ await writeSnapshot(workspace, testCase.input);
35
+ const edit = createEditToolDefinition(workspace);
36
+ try {
37
+ for (const [index, call] of testCase.nativeCalls.entries()) {
38
+ await edit.execute(`native-${index}`, call, undefined, undefined, { cwd: workspace });
39
+ }
40
+ if (testCase.outcomes.native !== "pass") {
41
+ assert.fail(`${testCase.id}: native edit unexpectedly succeeded`);
42
+ }
43
+ assert.deepEqual(await readSnapshot(workspace), testCase.expected);
44
+ return "pass";
45
+ } catch (error) {
46
+ if (testCase.outcomes.native === "pass") throw error;
47
+ return "fail";
48
+ }
49
+ } finally {
50
+ await rm(workspace, { recursive: true, force: true });
51
+ }
52
+ }
53
+
54
+ async function getCodexTool() {
55
+ let tool;
56
+ await registerCodexEdit({
57
+ registerTool(definition) { tool = definition; },
58
+ on() {},
59
+ getActiveTools() { return []; },
60
+ });
61
+ assert.ok(tool, "Codex Edit extension did not register apply_patch");
62
+ return tool;
63
+ }
64
+
65
+ async function runCodexCase(tool, testCase) {
66
+ const workspace = await mkdtemp(join(tmpdir(), "codex-edit-local-"));
67
+ try {
68
+ await writeSnapshot(workspace, testCase.input);
69
+ try {
70
+ await tool.execute("codex-local", { patch: testCase.patch }, undefined, undefined, {
71
+ cwd: workspace,
72
+ hasUI: false,
73
+ });
74
+ if (testCase.outcomes.local !== "pass") {
75
+ assert.fail(`${testCase.id}: local Codex Edit unexpectedly succeeded`);
76
+ }
77
+ assert.deepEqual(await readSnapshot(workspace), testCase.expected);
78
+ return "pass";
79
+ } catch (error) {
80
+ if (testCase.outcomes.local === "pass") throw error;
81
+ assert.deepEqual(
82
+ await readSnapshot(workspace),
83
+ testCase.input,
84
+ `${testCase.id}: failed Codex patch must not partially mutate the workspace`,
85
+ );
86
+ return "fail";
87
+ }
88
+ } finally {
89
+ await rm(workspace, { recursive: true, force: true });
90
+ }
91
+ }
92
+
93
+ export default async function () {
94
+ const codexTool = await getCodexTool();
95
+ const results = [];
96
+ for (const testCase of comparisonCases) {
97
+ const native = await runNativeCase(testCase);
98
+ const codex = await runCodexCase(codexTool, testCase);
99
+ results.push({ id: testCase.id, native, codex });
100
+ }
101
+ assert.equal(results.length, comparisonCases.length);
102
+ assert.equal(results.filter(({ native }) => native === "pass").length, 5);
103
+ assert.equal(results.filter(({ native }) => native === "fail").length, 2);
104
+ assert.equal(results.filter(({ native }) => native === "unsupported").length, 5);
105
+ assert.equal(results.filter(({ codex }) => codex === "pass").length, 7);
106
+ assert.equal(results.filter(({ codex }) => codex === "fail").length, 5);
107
+ for (const result of results) console.log(`${result.id}: native=${result.native}, codex=${result.codex}`);
108
+ console.log(`Native edit vs local Codex Edit: ${results.length} comparison cases passed`);
109
+ }
@@ -12,6 +12,8 @@ test("package points Pi at the extension and config is reachable from it", async
12
12
  const source = await readFile(join(packageDir, "extensions/codex-edit.ts"), "utf8");
13
13
  assert.match(source, /new URL\("\.\.\/config\.json", import\.meta\.url\)/);
14
14
  assert.match(source, /model-routing\.ts/);
15
+ assert.match(source, /parameters: Type\.Object\(\{\s*patch: Type\.String/);
16
+ assert.match(source, /applyUpdateGroups\(before, operation\.chunkGroups, operation\.path\)/);
15
17
  const config = JSON.parse(await readFile(join(packageDir, "config.json"), "utf8"));
16
18
  assert.equal(config.enabled, true);
17
19
  assert.deepEqual(config.models, ["gpt-5.6-*"]);
@@ -41,3 +43,9 @@ test("matches allowed model IDs with globs independently of provider and grammar
41
43
  assert.equal(modelIsAllowed({ ...baseModel, id: "GPT-5.6-terra" }, settings), false);
42
44
  assert.equal(modelIsAllowed(baseModel, { enabled: false, models: ["gpt-5.6-*"] }), false);
43
45
  });
46
+
47
+ test("execution keeps a resolved-path conflict guard after parser coalescing", async () => {
48
+ const source = await readFile(join(packageDir, "extensions/codex-edit.ts"), "utf8");
49
+ assert.match(source, /new Set\(targets\)\.size !== targets\.length/);
50
+ assert.match(source, /Multiple patch operations target the same resolved path/);
51
+ });
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import test from "node:test";
6
- import { applyUpdate, parsePatch, resolveWorkspacePath } from "../src/parser.ts";
6
+ import { applyUpdate, applyUpdateGroups, parsePatch, resolveWorkspacePath } from "../src/parser.ts";
7
7
 
8
8
  test("parses add, delete, update, and move operations", () => {
9
9
  const operations = parsePatch(`*** Begin Patch
@@ -162,6 +162,128 @@ test("parses multiple file operations used by structural refactors", () => {
162
162
  );
163
163
  });
164
164
 
165
+ test("coalesces repeated updates to one path and preserves operation order", () => {
166
+ const operations = parsePatch(`*** Begin Patch
167
+ *** Update File: src/tool.ts
168
+ @@
169
+ -const value = 1;
170
+ +const value = 2;
171
+ *** Update File: src/tool.ts
172
+ @@
173
+ -const value = 2;
174
+ +const value = 3;
175
+ *** End Patch`);
176
+ assert.equal(operations.length, 1);
177
+ assert.equal(operations[0].path, "src/tool.ts");
178
+ assert.equal(operations[0].chunkGroups.length, 2);
179
+ const after = applyUpdateGroups("const value = 1;\n", operations[0].chunkGroups, "src/tool.ts");
180
+ assert.equal(after, "const value = 3;\n");
181
+ });
182
+
183
+ test("still rejects conflicting repeated targets", () => {
184
+ assert.throws(() => parsePatch(`*** Begin Patch
185
+ *** Update File: src/tool.ts
186
+ @@
187
+ -old
188
+ +new
189
+ *** Delete File: src/tool.ts
190
+ *** End Patch`), /same path/);
191
+ assert.throws(() => parsePatch(`*** Begin Patch
192
+ *** Update File: src/old.ts
193
+ *** Move to: src/new.ts
194
+ @@
195
+ -old
196
+ +new
197
+ *** Add File: src/new.ts
198
+ +replacement
199
+ *** End Patch`), /same path/);
200
+ });
201
+
202
+ test("rejects empty chunks and stale expected lines instead of guessing", () => {
203
+ assert.throws(() => parsePatch(`*** Begin Patch
204
+ *** Update File: src/tool.ts
205
+ @@
206
+ *** End Patch`), /Empty update chunk/);
207
+ const [operation] = parsePatch(`*** Begin Patch
208
+ *** Update File: src/tool.ts
209
+ @@
210
+ -missing line
211
+ +replacement
212
+ *** End Patch`);
213
+ assert.throws(
214
+ () => applyUpdate("actual line\n", operation.chunks, operation.path),
215
+ /Failed to find expected lines in src\/tool\.ts/,
216
+ );
217
+ });
218
+
219
+ test("handles the reported project-scale repeated-file and Markdown shapes", () => {
220
+ const operations = parsePatch(`*** Begin Patch
221
+ *** Update File: skills/pi-worktree/SKILL.md
222
+ @@
223
+ -description: old
224
+ +description: updated
225
+ @@
226
+ ---
227
+ +Release notes follow.
228
+ *** Update File: packages/tiny-subagent/src/tool.ts
229
+ @@
230
+ -const state = "old";
231
+ +const state = "middle";
232
+ *** Update File: packages/tiny-subagent/src/tool.ts
233
+ @@
234
+ -const state = "middle";
235
+ +const state = "new";
236
+ *** Update File: scripts/release-package.sh
237
+ @@
238
+ -ROOT_LEVEL=patch
239
+ +ROOT_LEVEL=minor
240
+ *** Update File: scripts/release-package.sh
241
+ @@
242
+ -ROOT_LEVEL=minor
243
+ +ROOT_LEVEL=patch
244
+ *** End Patch`);
245
+ assert.deepEqual(operations.map((operation) => operation.path), [
246
+ "skills/pi-worktree/SKILL.md",
247
+ "packages/tiny-subagent/src/tool.ts",
248
+ "scripts/release-package.sh",
249
+ ]);
250
+ assert.equal(operations[1].chunkGroups.length, 2);
251
+ assert.equal(operations[2].chunkGroups.length, 2);
252
+
253
+ const skill = `---
254
+ name: pi-worktree
255
+ description: old
256
+ ---
257
+
258
+ # pi-worktree
259
+
260
+ ---
261
+ Footer
262
+ `;
263
+ assert.equal(
264
+ applyUpdateGroups(skill, operations[0].chunkGroups, operations[0].path),
265
+ `---
266
+ name: pi-worktree
267
+ description: updated
268
+ ---
269
+ Release notes follow.
270
+
271
+ # pi-worktree
272
+
273
+ ---
274
+ Footer
275
+ `,
276
+ );
277
+ assert.equal(
278
+ applyUpdateGroups('const state = "old";\n', operations[1].chunkGroups, operations[1].path),
279
+ 'const state = "new";\n',
280
+ );
281
+ assert.equal(
282
+ applyUpdateGroups("ROOT_LEVEL=patch\n", operations[2].chunkGroups, operations[2].path),
283
+ "ROOT_LEVEL=patch\n",
284
+ );
285
+ });
286
+
165
287
  test("rejects workspace escapes", () => {
166
288
  assert.equal(resolveWorkspacePath("/tmp/project", "/tmp/project/src/a.js"), "/tmp/project/src/a.js");
167
289
  assert.throws(() => resolveWorkspacePath("/tmp/project", "/tmp/other/a.js"), /escapes/);
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { applyUpdate, parsePatch } from "../src/parser.ts";
4
+ import { comparisonCases } from "./comparison-cases.mjs";
5
+ import { runPatchCase } from "./comparison-harness.mjs";
6
+ import { applyLegacyUpdate, parseLegacyPatch } from "./legacy-parser-v0.1.7.mjs";
7
+
8
+ test("released v0.1.7 versus local parser over the growing comparison corpus", () => {
9
+ const results = [];
10
+ for (const testCase of comparisonCases) {
11
+ const released = runPatchCase(
12
+ testCase,
13
+ parseLegacyPatch,
14
+ applyLegacyUpdate,
15
+ testCase.outcomes.released,
16
+ );
17
+ const local = runPatchCase(testCase, parsePatch, applyUpdate, testCase.outcomes.local);
18
+ results.push({ id: testCase.id, released, local });
19
+ }
20
+
21
+ assert.equal(results.length, comparisonCases.length);
22
+ assert.deepEqual(
23
+ results.find(({ id }) => id === "repeated-update-same-path"),
24
+ { id: "repeated-update-same-path", released: "fail", local: "pass" },
25
+ );
26
+ assert.equal(results.filter(({ released }) => released === "pass").length, 6);
27
+ assert.equal(results.filter(({ released }) => released === "fail").length, 6);
28
+ assert.equal(results.filter(({ local }) => local === "pass").length, 7);
29
+ assert.equal(results.filter(({ local }) => local === "fail").length, 5);
30
+ for (const result of results) console.log(`${result.id}: released=${result.released}, local=${result.local}`);
31
+ });
@@ -0,0 +1,41 @@
1
+ ## 2026-10-08 - v0.2.0
2
+
3
+ - replace Tiny Subagent with visible interactive Herdr agents and durable batch workflows
4
+ - Skip tool and command registration outside a Herdr workspace so unavailable Subagent capabilities do not enter the model context.
5
+ - Preserve newly allocated panes when blocked runs resume, stop blindly retrying Herdr resource-creating commands after uncertain failures, and prevent concurrent historical resumes from writing the same Pi session.
6
+ - Isolate malformed queue/history entries so healthy active runs and durable history remain recoverable, and share durable attempt settlement plus task projection contracts across execution modes.
7
+ - Retain blocked Fleet and dispatcher ownership after stateless response errors, and include public test entrypoints in the published package.
8
+ - Preserve every pending stale-tab cleanup across repeated pane migrations, retry all of them during startup reconciliation, and surface allocator cleanup failures through batch updates.
9
+ - Keep persisted reports authoritative even when Herdr transport fails after submission, persist failure reports for historical follow-ups, reject resume attempts against active or cleanup-pending runs, recover terminal deferred cleanup on startup, preserve cancelled ownership on shutdown, keep recovered Agent attempts aligned with durable turns, archive malformed turns, remove rejected background queue entries, support Pi enhanced keyboard sequences and authoritative status rendering, centralize lifecycle metadata, surface control-plane and cleanup failures, merge deferred cleanup against current durable state, and force-close focused tabs when parent shutdown is already active.
10
+ - Share strict report parsing across live and recovered paths, validate durable task ordering, surface unreadable queue/history records, reconcile prior sessions even within one Pi process, keep settled Fleet rows navigable, preserve surviving sibling panes during dead-pane recovery, and retain cleanup diagnostics.
11
+ - Keep queued and blocked siblings alive when one blocked response fails, centralize authoritative run-status transitions, and bind focus-deferred tab cleanup to parent-session shutdown.
12
+ - Retain dispatcher and Fleet ownership while a batch is blocked, centralize task/turn transitions, surface transient pane lookup failures, and notify deferred single-task cleanup failures.
13
+ - Fail reconciliation when task records are unreadable, archive malformed child reports before replacing them with valid failure records, and keep durable history and saved-session follow-up usable.
14
+ - Keep startup reconciliation moving past truncated child reports, resume dispatch after recovery errors, clear stale blocked fields after authoritative follow-ups, and surface deferred cleanup failures.
15
+ - Stream Fleet row and pane updates while a blocked answer resumes work and releases queued siblings.
16
+ - Keep the active Fleet widget stable when another background run is queued, refresh rows and pane navigation after blocked responses, and cover the public extension UI/input wiring with injectable dependencies.
17
+ - Centralize parent ownership, run-status aggregation, and serialized pane allocation across initial, continued, recovered, and historical runs.
18
+ - Run every initial task, blocked response, recovered task, and historical follow-up as a visible interactive Pi Agent through Herdr Agent start/prompt; remove print mode, pane shell injection, completion markers, and marker polling.
19
+ - Give every started task a new on-demand pane, retain completed panes until the batch settles, and then close the whole tab with focus-safe cleanup.
20
+ - Add a flat fully described tool schema with runtime mode validation and a Fleet-style task list with direct keyboard pane navigation.
21
+ - Continue historical sessions as durable turns of the same logical task, and cancel/archive unfinished work when the parent Pi session closes.
22
+ - Persist and reconcile queued/running work across parent shutdown, avoid retrying uncertain startup operations, preserve post-submit failures, warn without timing out stalled children, and atomically replace state snapshots.
23
+ - Add compact project-local history, saved-session follow-ups in new Herdr tabs, explicit per-run cleanup, unlimited retention, and generated-state Git exclusion.
24
+ - Support structured `needs-input` reports, preserve blocked panes and sessions, and resume the original child with `action: respond`.
25
+ - Queue background batches behind one active run, return stable IDs immediately, keep progress visible, and send one compact run-level parent notification.
26
+ - Show active run counts in a persistent widget, expose compact `/herdr-subagents active` and pane focus navigation, update progress labels, and defer cleanup while a completed tab is focused.
27
+ - Execute ordered read-only batches with FIFO bounded concurrency, stable result order, and sibling failure isolation; force write-capable runs to serial execution.
28
+ - Add built-in worker, explorer, and reviewer personas with project/global/package discovery precedence.
29
+ - Add merged global and project model, thinking, skill, and concurrency settings plus `/herdr-subagents` discovery commands.
30
+ - Run one synchronous subagent in a dedicated, unfocused Herdr tab and close it after completion.
31
+ - Require a structured child report and return only its summary and document paths to the parent.
32
+ - Persist run/task metadata, the full result, and the child Pi session under `.pi/herdr-subagents/runs`.
33
+
34
+ ## 2026-09-30 - v0.1.1
35
+
36
+ - Add standalone testing documentation and production-runner screenshot/demo media.
37
+ - Align the package manifest and root bundle with the shared workspace source.
38
+
39
+ ## 2026-09-01 - v0.1.0
40
+
41
+ - initial release
@@ -0,0 +1,155 @@
1
+ # @maxiaochao/pi-herdr-subagents
2
+
3
+ A Pi extension that runs focused foreground or background work as normal interactive Pi Agents in visible Herdr panes. Full answers and sessions stay in project-local records while the parent receives only concise summaries and document paths.
4
+
5
+ ![Herdr Subagents production runner screenshot](docs/screenshot.png)
6
+
7
+ ## Demo
8
+
9
+ ![Herdr Subagents demo](docs/demo.gif)
10
+
11
+ ## Requirements and install
12
+
13
+ Run Pi inside Herdr, then install the package:
14
+
15
+ ```bash
16
+ pi install npm:@maxiaochao/pi-herdr-subagents
17
+ ```
18
+
19
+ Update an existing standalone installation with:
20
+
21
+ ```bash
22
+ pi update npm:@maxiaochao/pi-herdr-subagents
23
+ ```
24
+
25
+ The full `@maxiaochao/pi-toolkit` package also bundles this extension. Install the full toolkit or this standalone child package, not both.
26
+
27
+ Inside a Herdr workspace, the package registers `herdr_subagents` and `/herdr-subagents`. Outside Herdr (or without a workspace ID), the extension registers neither, so the tool is absent from the model context. Start Pi inside Herdr or reload the extension there to enable them.
28
+
29
+ The design replaces hidden print-mode subprocesses with ordinary interactive Pi Agents managed through Herdr, so delegated work stays visible, inspectable, resumable, and durably recorded.
30
+
31
+ ## Usage
32
+
33
+ Discover the effective personas first:
34
+
35
+ ```text
36
+ herdr_subagents({ action: "list" })
37
+ ```
38
+
39
+ Then run one self-contained task:
40
+
41
+ ```text
42
+ herdr_subagents({
43
+ agent: "worker",
44
+ label: "auth-review",
45
+ prompt: "Inspect the authentication flow and summarize the risks"
46
+ })
47
+ ```
48
+
49
+ Or submit an ordered foreground batch:
50
+
51
+ ```text
52
+ herdr_subagents({
53
+ label: "security-review",
54
+ concurrency: 3,
55
+ tasks: [
56
+ { name: "auth", agent: "reviewer", prompt: "Review authentication" },
57
+ { name: "sessions", agent: "explorer", prompt: "Trace session storage" }
58
+ ]
59
+ })
60
+ ```
61
+
62
+ Set `background: true` to return a stable run ID immediately while the queue continues. One run is dispatched at a time by an extension instance; later runs remain queued and start FIFO. Each background run sends one compact parent notification only when the whole run completes, partially fails, fails, or blocks. Individual successful tasks do not wake the parent model.
63
+
64
+ A child missing required information reports `needs-input` with an exact question. The run becomes blocked, keeps its Herdr tab, pane, partial report, and Pi session, and returns control to the parent. Answer through the same tool to continue the original session:
65
+
66
+ ```text
67
+ herdr_subagents({ action: "respond", runId: "<run-id>", answer: "Use the main branch." })
68
+ ```
69
+
70
+ While a batch is active, its task rows stay below the editor. With an empty editor, press `↓` to enter the list, `↑`/`↓` to choose a task, `Enter` to focus its exact pane, and `Esc` to return. `/herdr-subagents focus <task-number>` remains available as a fallback.
71
+
72
+ ## History
73
+
74
+ Run `/herdr-subagents history` or `herdr_subagents({ action: "history" })` to list compact project-local run and task reports after Herdr tabs close. Full results, described documents, artifacts, and persistent Pi sessions remain separate on disk and are not loaded into parent context.
75
+
76
+ Continue a saved conversation in a new Herdr tab with `herdr_subagents({ action: "resume", runId: "<run-id>", task: 1, prompt: "Follow up..." })`. Remove only a selected archive with `herdr_subagents({ action: "cleanup", runId: "<run-id>" })`. History has no automatic count-based eviction. The extension adds the runtime path to local Git excludes when available (and otherwise uses `.pi/.gitignore`) without overwriting unrelated rules.
77
+
78
+ Read-only tasks start FIFO up to the configured concurrency limit. A pane is created only when its task starts, belongs exclusively to that task, and is never reused. Completed panes remain inspectable while another task in the batch is unsettled. Results remain in input order, and one failed task does not cancel its siblings. Any batch containing a write-capable persona is forced to concurrency one.
79
+
80
+ The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task refreshes rows and pane IDs as that task resumes and queued siblings start. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning.
81
+
82
+ Foreground calls block until all children report completion, failure, or a request for input. Every child is started with Herdr's Agent lifecycle as a normal Pi TUI and prompted with wait semantics; no print-mode command or completion marker is injected into the pane.
83
+
84
+ ## Reports and records
85
+
86
+ The child must finish through the package's structured `subagent_report` tool. The parent receives only:
87
+
88
+ - completion or failure status
89
+ - a concise summary
90
+ - paths to useful documents
91
+ - the task-record location
92
+
93
+ The complete result is not copied into the parent model context. Each run is retained under:
94
+
95
+ ```text
96
+ .pi/herdr-subagents/runs/<run>/tasks/<task>/
97
+ ```
98
+
99
+ The task directory contains the full result, structured report, task metadata, system prompt, and persistent Pi session. Run-level metadata is stored in the parent run directory.
100
+
101
+ ## Personas
102
+
103
+ The package includes `worker` (write access), `explorer` (read access), and `reviewer` (read access). Persona definitions are discovered in this order, with the first available higher-precedence definition replacing the lower one:
104
+
105
+ 1. `.pi/herdr-subagents/agents/*.md` in the project
106
+ 2. `~/.pi/agent/herdr-subagents/agents/*.md` globally
107
+ 3. this package's `agents/*.md`
108
+
109
+ A custom persona without `access` is treated as write-capable. Definitions may set `access`, `model`, `thinking`, `tools`, and `skills` in frontmatter.
110
+
111
+ The default `agents/worker.md` uses this format:
112
+
113
+ ```md
114
+ ---
115
+ name: worker
116
+ description: General-purpose worker for one focused task
117
+ tools: read, bash, edit, write, grep, find, ls
118
+ ---
119
+
120
+ You are a focused worker subagent.
121
+ ```
122
+
123
+ Supported frontmatter:
124
+
125
+ - `name` and `description` are required.
126
+ - `model` optionally selects a child model. A call-level `model` overrides it; otherwise the current session model is inherited.
127
+ - `tools` optionally restricts built-in tools. `subagent_report` is always added so the child can settle the task.
128
+ - `access` is `read` or `write`; omitted custom values default to `write`.
129
+ - `thinking` and `skills` provide persona defaults.
130
+
131
+ Global settings live at `~/.pi/agent/herdr-subagents.json`; project overrides live at `.pi/herdr-subagents.json`. Both support `defaultConcurrency`, `maxConcurrency`, `stalledWarningSeconds`, `defaultModel`, and a `personas` object whose entries can override `model`, `thinking`, and `skills`.
132
+
133
+ ```json
134
+ {
135
+ "defaultModel": "provider/model",
136
+ "maxConcurrency": 3,
137
+ "personas": {
138
+ "reviewer": { "thinking": "high", "skills": ["code-review"] }
139
+ }
140
+ }
141
+ ```
142
+
143
+ Project values override global values recursively. Model resolution is: task override, project persona setting, global persona setting, persona frontmatter, global/project default model, then parent model. Run `/herdr-subagents agents`, `/herdr-subagents models`, or `/herdr-subagents settings` to inspect effective values and their sources.
144
+
145
+ ## Recovery and failures
146
+
147
+ Queued background submissions are persisted before dispatch. Closing the parent Pi session cancels queued, running, and blocked work, records `cancelled`, and closes the owned run tab. On the next Pi start, stale active records and queued submissions from a prior parent session are reconciled to a terminal state, including when Pi reused the same process, and leaked tabs are cleaned up when possible. Missing, truncated, or structurally invalid child reports become explicit failures or cancellation diagnostics; recovery errors are reported without leaving new submissions paused.
148
+
149
+ Tab, pane, and child startup commands are not automatically retried: an error may arrive after Herdr already created the resource. A failure after prompt submission is preserved and never automatically rerun. Historical follow-ups exclude concurrent resumes of the same run with an atomic `.resume.lock` directory; if Pi crashes without releasing it, confirm no Agent is still using that run before removing the lock manually. `stalledWarningSeconds` emits a warning without killing long-running work. Run/task/report snapshots use atomic replacement so interruption cannot expose half-written JSON.
150
+
151
+ The child does not inherit parent extensions, parent conversation, or unconfigured skills, but normal project context files still apply. It uses the same Pi installation, provider configuration, model catalog, and credentials as the parent.
152
+
153
+ ## Development and tests
154
+
155
+ See [TESTING.md](TESTING.md) for deterministic tests, extension loading, packaging, and real Herdr smoke requirements.
@@ -0,0 +1,24 @@
1
+ # Herdr Subagents test strategy
2
+
3
+ ## Flow
4
+
5
+ 1. `npm --prefix packages/herdr-subagents test` runs deterministic scheduler, recovery, and persona tests.
6
+ 2. `pi -ne -ns --no-session -e ./packages/herdr-subagents/extensions/herdr-subagents.ts` verifies extension loading.
7
+ 3. `npm pack ./packages/herdr-subagents --dry-run` verifies package contents.
8
+ 4. Verify the README example and the production-runner media `docs/screenshot.png` and `docs/demo.gif` are present in the tarball.
9
+
10
+ ## Cases
11
+
12
+ - Resolve package, global, and project personas and validate frontmatter/configuration.
13
+ - Run foreground tasks and FIFO bounded-concurrency batches through an injectable Herdr boundary.
14
+ - Verify all paths use interactive Agent start/prompt without print mode, shell injection, markers, or pane reuse.
15
+ - Force write-capable batches to serial execution while allowing bounded read-only parallelism.
16
+ - Preserve compact reports, full results, Pi sessions, blocked questions, and historical follow-ups.
17
+ - Recover queued/running work after interruption without retrying post-prompt side effects, including truncated reports and recovery errors.
18
+ - Verify the flat public contract and injectable extension wiring, including conditional registration only inside Herdr, Fleet rendering, editor focus ownership, terminal-input passthrough, exact-pane navigation, and stable display while another background run queues.
19
+ - Verify blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
20
+ - Verify resumed batches retain newly allocated pane IDs and historical follow-ups exclude simultaneous session writers.
21
+
22
+ ## Latest baseline
23
+
24
+ All 133 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, and persona cases must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: explorer
3
+ description: Read-only explorer for fast codebase investigation
4
+ access: read
5
+ tools: read, bash, grep, find, ls
6
+ ---
7
+
8
+ Investigate the assigned question without modifying files. Report concrete evidence and useful paths.