@maxiaochao/pi-toolkit 0.13.7 → 0.13.9

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 (48) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +5 -1
  3. package/extensions/btw/index.ts +7 -16
  4. package/extensions/btw/tests/context.mjs +78 -0
  5. package/package.json +4 -3
  6. package/packages/cache-export/CHANGELOG.md +11 -0
  7. package/packages/cache-export/README.md +14 -0
  8. package/packages/cache-export/TESTING.md +8 -2
  9. package/packages/cache-export/docs/demo.gif +0 -0
  10. package/packages/cache-export/docs/screenshot.png +0 -0
  11. package/packages/cache-export/extensions/cache-export.ts +44 -3
  12. package/packages/cache-export/package.json +24 -6
  13. package/packages/cache-export/src/render.ts +120 -59
  14. package/packages/cache-export/tests/e2e-browser.mjs +45 -5
  15. package/packages/cache-export/tests/evidence.mjs +113 -0
  16. package/packages/cache-export/tests/extension.mjs +52 -0
  17. package/packages/cache-export/tests/run-tests.mjs +12 -11
  18. package/packages/codex-edit/CHANGELOG.md +7 -0
  19. package/packages/codex-edit/README.md +4 -0
  20. package/packages/codex-edit/TESTING.md +4 -0
  21. package/packages/codex-edit/docs/test-report.html +19 -11
  22. package/packages/codex-edit/docs/test-report.md +22 -13
  23. package/packages/codex-edit/docs/test-summary.md +1 -1
  24. package/packages/codex-edit/extensions/codex-edit.ts +12 -4
  25. package/packages/codex-edit/tests/comparison-cases.mjs +35 -0
  26. package/packages/codex-edit/tests/comparison-harness.mjs +5 -6
  27. package/packages/codex-edit/tests/generate-test-report.mjs +12 -7
  28. package/packages/codex-edit/tests/native-vs-codex.ts +14 -14
  29. package/packages/codex-edit/tests/released-vs-local.test.mjs +4 -0
  30. package/packages/herdr-subagents/ARCHITECTURE.md +4 -1
  31. package/packages/herdr-subagents/CHANGELOG.md +12 -0
  32. package/packages/herdr-subagents/README.md +11 -2
  33. package/packages/herdr-subagents/TESTING.md +12 -0
  34. package/packages/herdr-subagents/docs/demo.webm +0 -0
  35. package/packages/herdr-subagents/docs/screenshot.png +0 -0
  36. package/packages/herdr-subagents/extensions/subagent-report.ts +84 -30
  37. package/packages/herdr-subagents/package.json +2 -2
  38. package/packages/herdr-subagents/tests/report-boundary.mjs +83 -0
  39. package/packages/herdr-subagents/tests/report-lifecycle.mjs +69 -0
  40. package/packages/herdr-subagents/tests/runtime.mjs +26 -0
  41. package/packages/herdr-subagents/tests/standalone-package.mjs +23 -0
  42. package/packages/worktree/CHANGELOG.md +4 -0
  43. package/packages/worktree/README.md +4 -0
  44. package/packages/worktree/TESTING.md +4 -0
  45. package/packages/worktree/bin/pi-worktree +1 -1
  46. package/packages/worktree/tests/run-tests.sh +10 -0
  47. package/skills/web-browser/SKILL.md +3 -1
  48. package/skills/web-browser/scripts/start.js +42 -8
@@ -82,9 +82,12 @@ There is no global dispatcher, persisted background queue, blocked response lane
82
82
 
83
83
  Each task owns one Herdr pane and one single-task Pi process. Herdr first starts and detects the interactive Pi, then submits the prompt exactly once. At Pi’s final `agent_settled` boundary, the child extension records settled state but leaves the interactive process alive. The atomic report remains the authoritative task result. Early-finishing children remain visible while sibling tasks continue. Once the whole run settles, the orchestrator closes an unfocused owned tab immediately and defers a focused tab until the user leaves it, so Pi can restore the terminal normally.
84
84
 
85
+ A completed round without a terminal report gets one child-side reminder at `agent_before_settle`. The marker is written before continuation. Another omission publishes an unconfirmed `missing_report` diagnostic; final errors use `child_error`. Native cancellation skips that boundary, so `agent_settled` publishes only `child_aborted`, never a model wake-up. Existing terminal reports are not replaced. None of this adds a parent monitoring loop.
86
+
85
87
  The child writes:
86
88
 
87
- - `results/<attempt-id>.md`
89
+ - `attempt-results/<attempt-id>.md`
90
+ - `attempts/<attempt-id>.reminded` (one durable reminder budget)
88
91
  - `reports/<attempt-id>.json`
89
92
 
90
93
  Both are attempt-scoped. The report is published with atomic rename. The orchestrator validates it, verifies the attempt identity, and promotes it to `report.json`.
@@ -1,3 +1,15 @@
1
+ ## 2026-10-23 - v0.7.5
2
+
3
+ - bound missing-report recovery and close unconfirmed child outcomes
4
+ - Regenerate real three-pane media and preserve configured model/authentication sources in the media parent launcher.
5
+
6
+ - Ship the lifecycle test runtime resolver inside the Herdr package and execute all ten offline lifecycle cases from a real unpacked tarball without sibling packages.
7
+
8
+ - At agent_before_settle, accept an existing terminal attempt report without another request; otherwise remind a completed child once to report existing work.
9
+ - Persist the reminder before continuation so rounds and reloads cannot reset its budget.
10
+ - Publish unconfirmed-result diagnostics after repeated omission or final provider error. Native cancellation bypasses before-settle, so agent_settled writes only a cancellation diagnostic, with no model wake-up.
11
+ - Preserve report idempotency, interactive child lifetime, and parent-side report polling. Add offline native Pi lifecycle regressions.
12
+
1
13
  ## 2026-10-22 - v0.7.4
2
14
 
3
15
  - preserve Codex patch editing in child allowlists and hide the parent shortcut footer hint
@@ -10,7 +10,7 @@ The implementation is intentionally small: one task state machine, one derived r
10
10
 
11
11
  https://github.com/user-attachments/assets/8ddf3ae6-a11a-492b-9cae-d9496ba65b67
12
12
 
13
- The reproducible source recording is retained as [`docs/demo.webm`](docs/demo.webm).
13
+ The embedded video is the previously published walkthrough. The current release acceptance recording is retained in full as [`docs/demo.webm`](docs/demo.webm).
14
14
 
15
15
  ## Requirements and install
16
16
 
@@ -96,7 +96,8 @@ Records are stored under:
96
96
  system-prompt.md
97
97
  report.json
98
98
  reports/<attempt-id>.json
99
- results/<attempt-id>.md
99
+ attempt-results/<attempt-id>.md
100
+ attempts/<attempt-id>.reminded
100
101
  ```
101
102
 
102
103
  The orchestrator is the only writer of `run.json`, `task.json`, and promoted `report.json`. The child only writes its attempt-scoped result and report.
@@ -348,3 +349,11 @@ Use `/herdr-subagents agents`, `/herdr-subagents models`, and `/herdr-subagents
348
349
  ## Development and tests
349
350
 
350
351
  See [TESTING.md](TESTING.md) for deterministic tests, extension loading, packaging, and real Herdr smoke requirements. The real smoke preserves your configured `analyst` model, thinking level, and explicit extensions (including authentication providers) inside its isolated fixture; unrelated Persona settings remain isolated.
352
+
353
+ ## Missing reports and terminal diagnostics
354
+
355
+ The child checks `reports/<attempt-id>.json` at `agent_before_settle`. A valid completed or failed report adds zero requests. A normally completed round without a report receives one reminder to submit existing results, not redo the task. `attempts/<attempt-id>.reminded` is persisted before continuing; rounds and reloads cannot reset it.
356
+
357
+ A second omission produces a `failed` protocol report with `error: missing_report`, an explicitly **unconfirmed** result, and the child session path. This does not assert the delegated work itself failed. Final provider errors produce `child_error` without a reminder. Pi skips before-settle on cancellation; the child writes `child_aborted` at `agent_settled`, without waking the model or replacing an existing report. Parent-requested cancellation retains its existing cancelled lifecycle.
358
+
359
+ The parent still waits for an atomic attempt report or process exit. It does not infer success from idle/settled or run a second reminder monitor. Report idempotency and focus-aware tab cleanup are unchanged.
@@ -57,3 +57,15 @@ This preserves authentication extensions such as `npm:@maxiaochao/pi-openai-acco
57
57
  `HERDR_SUBAGENTS_E2E_MODEL=provider/model` overrides only the smoke model; configured extensions remain loaded. Set `HERDR_SUBAGENTS_E2E_FOCUS_CLEANUP=1` to also verify deferred cleanup while focused and restoration of the original workspace/tab focus.
58
58
 
59
59
  For real smoke testing, verify that each child appears in a visible pane and can be focused and inspected. An early-finishing child must remain visible while sibling tasks continue. After the whole run settles, a focused child must remain at its restored Pi UI until focus leaves the run tab; the tab must then close automatically. Verify that the parent returns only after all requested tasks settle. Also verify cancellation closes the run tab and does not overwrite a report already atomically published.
60
+
61
+ ## Missing-report regressions
62
+
63
+ `tests/report-boundary.mjs` checks completed/failed report idempotency, one durable reminder across rounds and extension reload, conflicting report rejection, unconfirmed diagnostics with a session path, and cancellation/error without continuation.
64
+
65
+ `tests/report-lifecycle.mjs` uses the real installed Pi SDK and a fully offline provider. It covers ten cases: normal report, failed report, successful reminder, repeated omission, transient retry, exhausted retry, queued follow-up, another before-settle continuation, cancellation, and a fresh prompt from a settled listener. A normal report makes one request; a reminder adds at most one. Native cancellation does not emit before-settle and must still get a terminal diagnostic. These are native lifecycle checks, not a claim of real-Herdr pane/concurrency coverage.
66
+
67
+ Use `PI_TEST_BINARY` to select Pi 1.4.0 for these checks rather than an older workspace-local Pi. No paid model or real credential is used by the new regression fixtures.
68
+
69
+ `tests/standalone-package.mjs` packs the actual child package into a temporary directory, unpacks it outside the workspace, and runs all ten native/offline lifecycle scenarios there. `tests/runtime.mjs` resolves peers from the selected installed Pi; no sibling package or copied Pi runtime is needed.
70
+
71
+ The media recorder also uses the effective analyst model, thinking level and explicit extension sources for its parent Pi. `PI_HERDR_MEDIA_MODEL` overrides only the model; required authentication extensions remain loaded. It does not copy or expose credentials.
@@ -4,7 +4,8 @@ import { dirname, resolve } from "node:path";
4
4
  import { StringEnum } from "@earendil-works/pi-ai";
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { Text } from "@earendil-works/pi-tui";
7
- import { Type } from "typebox";
7
+ import { Type, type Static } from "typebox";
8
+ import { parseReport } from "../src/records.ts";
8
9
  import { resultPathForAttemptId } from "../src/result-path.ts";
9
10
  import { PARENT_PANE_ENV } from "../src/live-agent.ts";
10
11
  import { attemptReportPath, attemptStatePath, requireAttemptId, type AttemptState } from "../src/attempts.ts";
@@ -41,11 +42,53 @@ function writeAtomic(path: string, content: string): void {
41
42
  renameSync(temporary, path);
42
43
  }
43
44
 
45
+ function saveReport(taskDirectory: string, attemptId: string, params: Static<typeof ReportParams>) {
46
+ mkdirSync(taskDirectory, { recursive: true, mode: 0o700 });
47
+ const resultPath = resultPathForAttemptId(taskDirectory, attemptId);
48
+ const reportPath = attemptReportPath(taskDirectory, attemptId);
49
+ const documents = (params.documents ?? []).map((document) => ({
50
+ path: resolve(process.cwd(), document.path),
51
+ description: document.description,
52
+ }));
53
+ if (!documents.some((document) => document.path === resultPath)) {
54
+ documents.unshift({ path: resultPath, description: "Complete task result" });
55
+ }
56
+ const report = {
57
+ attemptId,
58
+ status: params.status,
59
+ summary: params.summary,
60
+ documents,
61
+ ...(params.error ? { error: params.error } : {}),
62
+ reportedAt: new Date().toISOString(),
63
+ };
64
+ if (existsSync(reportPath)) {
65
+ const existing = JSON.parse(readFileSync(reportPath, "utf8")) as Record<string, unknown>;
66
+ const comparable = ({ reportedAt: _reportedAt, ...value }: Record<string, unknown>) => value;
67
+ const sameReport = JSON.stringify(comparable(existing)) === JSON.stringify(comparable(report));
68
+ const sameResult = existsSync(resultPath) && readFileSync(resultPath, "utf8") === params.result;
69
+ if (!sameReport || !sameResult) throw new Error("Conflicting subagent_report already exists for this task.");
70
+ } else {
71
+ writeAtomic(resultPath, params.result);
72
+ writeAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`);
73
+ }
74
+ return report;
75
+ }
76
+
44
77
  function currentAttemptId(taskDirectory: string): string {
45
78
  const task = JSON.parse(readFileSync(resolve(taskDirectory, "task.json"), "utf8")) as { currentAttemptId?: unknown };
46
79
  return requireAttemptId(task.currentAttemptId);
47
80
  }
48
81
 
82
+ function saveUnconfirmed(taskDirectory: string, attemptId: string, reason: string, sessionFile: string | undefined): void {
83
+ saveReport(taskDirectory, attemptId, {
84
+ status: "failed",
85
+ summary: `${reason}: delegated result is unconfirmed; inspect the child session.`,
86
+ result: `No terminal subagent_report was submitted. Diagnostic: ${reason}.\nThis does not establish whether the delegated work succeeded or failed.\nChild session: ${sessionFile ?? "unavailable"}\n`,
87
+ error: reason,
88
+ documents: sessionFile ? [{ path: sessionFile, description: "Child session with unconfirmed task results" }] : [],
89
+ });
90
+ }
91
+
49
92
  function writeAttemptState(taskDirectory: string, attemptId: string, state: AttemptState): void {
50
93
  writeAtomic(attemptStatePath(taskDirectory, attemptId), `${JSON.stringify({ attemptId, state, at: new Date().toISOString() }, null, 2)}\n`);
51
94
  }
@@ -72,9 +115,47 @@ export default function (pi: ExtensionAPI) {
72
115
  writeAttemptState(taskDirectory, activeAttemptId, "working");
73
116
  });
74
117
  pi.on("agent_start", () => reportHerdrState("working"));
75
- pi.on("agent_settled", (_event, ctx) => {
118
+ pi.on("agent_before_settle", (event, ctx) => {
119
+ const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
120
+ if (!taskDirectory) return;
121
+ const attemptId = activeAttemptId ?? currentAttemptId(taskDirectory);
122
+ const reportPath = attemptReportPath(taskDirectory, attemptId);
123
+ if (existsSync(reportPath)) {
124
+ const report = parseReport(JSON.parse(readFileSync(reportPath, "utf8")), reportPath);
125
+ if (report.attemptId !== attemptId || report.status === "blocked") throw new Error("Invalid terminal attempt report.");
126
+ return; // A valid failed report is terminal too. Never ask for it again.
127
+ }
128
+ if (event.outcome === "completed") {
129
+ const reminderPath = resolve(taskDirectory, "attempts", `${attemptId}.reminded`);
130
+ mkdirSync(dirname(reminderPath), { recursive: true, mode: 0o700 });
131
+ try {
132
+ // Persist before continuing. Neither a new round nor /reload resets this budget.
133
+ writeFileSync(reminderPath, new Date().toISOString(), { flag: "wx", mode: 0o600 });
134
+ return {
135
+ entries: [...event.entries, {
136
+ type: "custom_message" as const,
137
+ customType: "herdr-subagents-missing-report",
138
+ content: "Do not redo the task. Submit the results you already have with subagent_report now. If you cannot confirm completion, report failed and explain what is unconfirmed.",
139
+ display: true,
140
+ }],
141
+ continue: true,
142
+ };
143
+ } catch (error) {
144
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
145
+ }
146
+ }
147
+ const reason = event.outcome === "completed" ? "missing_report" : event.outcome === "aborted" ? "child_aborted" : "child_error";
148
+ saveUnconfirmed(taskDirectory, attemptId, reason, ctx.sessionManager.getSessionFile());
149
+ // Cancellation and final provider errors receive a diagnostic, never a wake-up.
150
+ });
151
+ pi.on("agent_settled", (event, ctx) => {
76
152
  const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
77
153
  if (taskDirectory && activeAttemptId) {
154
+ // Pi skips before-settle on cancellation. Close the protocol without
155
+ // claiming the work failed, replacing a report, or requesting more tokens.
156
+ if (event.aborted && !existsSync(attemptReportPath(taskDirectory, activeAttemptId))) {
157
+ saveUnconfirmed(taskDirectory, activeAttemptId, "child_aborted", ctx.sessionManager.getSessionFile());
158
+ }
78
159
  writeAttemptState(taskDirectory, activeAttemptId, "settled");
79
160
  }
80
161
  // Keep the interactive child alive at Pi's final automatic-work boundary.
@@ -121,34 +202,7 @@ export default function (pi: ExtensionAPI) {
121
202
  const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
122
203
  if (!taskDirectory) throw new Error("PI_HERDR_SUBAGENTS_TASK_DIR is not set");
123
204
  const attemptId = activeAttemptId ?? currentAttemptId(taskDirectory);
124
- mkdirSync(taskDirectory, { recursive: true, mode: 0o700 });
125
- const resultPath = resultPathForAttemptId(taskDirectory, attemptId);
126
- const reportPath = attemptReportPath(taskDirectory, attemptId);
127
- const documents = (params.documents ?? []).map((document) => ({
128
- path: resolve(process.cwd(), document.path),
129
- description: document.description,
130
- }));
131
- if (!documents.some((document) => document.path === resultPath)) {
132
- documents.unshift({ path: resultPath, description: "Complete task result" });
133
- }
134
- const report = {
135
- attemptId,
136
- status: params.status,
137
- summary: params.summary,
138
- documents,
139
- ...(params.error ? { error: params.error } : {}),
140
- reportedAt: new Date().toISOString(),
141
- };
142
- if (existsSync(reportPath)) {
143
- const existing = JSON.parse(readFileSync(reportPath, "utf8")) as Record<string, unknown>;
144
- const comparable = ({ reportedAt: _reportedAt, ...value }: Record<string, unknown>) => value;
145
- const sameReport = JSON.stringify(comparable(existing)) === JSON.stringify(comparable(report));
146
- const sameResult = existsSync(resultPath) && readFileSync(resultPath, "utf8") === params.result;
147
- if (!sameReport || !sameResult) throw new Error("Conflicting subagent_report already exists for this task.");
148
- } else {
149
- writeAtomic(resultPath, params.result);
150
- writeAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`);
151
- }
205
+ const report = saveReport(taskDirectory, attemptId, params);
152
206
  return {
153
207
  content: [{ type: "text" as const, text: `${params.status}: ${params.summary}` }],
154
208
  details: report,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -37,7 +37,7 @@
37
37
  "typebox": "*"
38
38
  },
39
39
  "scripts": {
40
- "test": "node --experimental-strip-types tests/config.mjs && node --experimental-strip-types tests/core-state-machine.mjs && node --experimental-strip-types tests/real-herdr-config.test.mjs",
40
+ "test": "node --experimental-strip-types tests/config.mjs && node --experimental-strip-types tests/core-state-machine.mjs && node --experimental-strip-types tests/report-boundary.mjs && node --experimental-strip-types tests/report-lifecycle.mjs && node tests/standalone-package.mjs && node --experimental-strip-types tests/real-herdr-config.test.mjs",
41
41
  "test:real-herdr": "node --experimental-strip-types tests/real-herdr-smoke.mjs",
42
42
  "load-test": "env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./extensions/herdr-subagents.ts && node --experimental-strip-types tests/extension-sources-load.mjs",
43
43
  "test:legacy": "node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types tests/cleanup-timing.mjs"
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import registerReport from "../extensions/subagent-report.ts";
7
+
8
+ const root = mkdtempSync(join(tmpdir(), "herdr-report-boundary-"));
9
+ const previous = process.env.PI_HERDR_SUBAGENTS_TASK_DIR;
10
+ process.env.PI_HERDR_SUBAGENTS_TASK_DIR = root;
11
+ const sessionFile = join(root, "session.jsonl");
12
+ writeFileSync(sessionFile, "");
13
+ const ctx = { sessionManager: { getSessionFile: () => sessionFile }, isIdle: () => true };
14
+ function fixture() {
15
+ const handlers = new Map();
16
+ let tool;
17
+ registerReport({
18
+ on: (name, handler) => handlers.set(name, handler),
19
+ registerTool: (value) => { tool = value; },
20
+ registerCommand() {}, registerShortcut() {}, events: { on() {} },
21
+ });
22
+ return { handlers, tool };
23
+ }
24
+ function attempt() {
25
+ const id = randomUUID();
26
+ writeFileSync(join(root, "task.json"), JSON.stringify({ currentAttemptId: id }));
27
+ return { id, report: join(root, "reports", `${id}.json`) };
28
+ }
29
+ const boundary = (outcome = "completed") => ({ outcome, entries: [], continue: false, context: { canContinue: true } });
30
+ try {
31
+ const f = fixture();
32
+ assert.equal(typeof f.handlers.get("agent_before_settle"), "function", "child must handle the real pre-settlement boundary");
33
+ for (const status of ["completed", "failed"]) {
34
+ const a = attempt();
35
+ await f.handlers.get("before_agent_start")();
36
+ const input = { status, summary: status, result: "Already done", ...(status === "failed" ? { error: "Expected" } : {}) };
37
+ await f.tool.execute("first", input);
38
+ const original = readFileSync(a.report, "utf8");
39
+ assert.equal(await f.handlers.get("agent_before_settle")(boundary(), ctx), undefined);
40
+ await f.tool.execute("repeat", input);
41
+ await assert.rejects(() => f.tool.execute("conflict", { ...input, summary: "different" }), /Conflicting/);
42
+ assert.equal(readFileSync(a.report, "utf8"), original, "normal and failed reports need no extra request or replacement");
43
+ }
44
+ const a = attempt();
45
+ await f.handlers.get("before_agent_start")();
46
+ const reminder = await f.handlers.get("agent_before_settle")(boundary(), ctx);
47
+ assert.equal(reminder.continue, true);
48
+ assert.match(JSON.stringify(reminder.entries), /Do not redo|do not redo/);
49
+ assert.equal(existsSync(a.report), false, "a reminder is not a completion report");
50
+ // A new round and even extension reload must not reset the per-attempt budget.
51
+ const reloaded = fixture();
52
+ await reloaded.handlers.get("before_agent_start")();
53
+ assert.equal(await reloaded.handlers.get("agent_before_settle")(boundary(), ctx), undefined);
54
+ const diagnostic = JSON.parse(readFileSync(a.report, "utf8"));
55
+ assert.equal(diagnostic.status, "failed");
56
+ assert.equal(diagnostic.error, "missing_report");
57
+ assert.match(diagnostic.summary, /unconfirmed/);
58
+ assert.ok(diagnostic.documents.some(({ path }) => path === sessionFile));
59
+ assert.equal(await reloaded.handlers.get("agent_before_settle")(boundary(), ctx), undefined);
60
+
61
+ for (const outcome of ["aborted", "error"]) {
62
+ const current = attempt();
63
+ const run = fixture();
64
+ await run.handlers.get("before_agent_start")();
65
+ assert.equal(await run.handlers.get("agent_before_settle")(boundary(outcome), ctx), undefined, `${outcome}: do not wake or retry the child`);
66
+ const report = JSON.parse(readFileSync(current.report, "utf8"));
67
+ assert.equal(report.status, "failed");
68
+ assert.equal(report.error, outcome === "aborted" ? "child_aborted" : "child_error");
69
+ assert.match(report.summary, /unconfirmed/);
70
+ }
71
+ const repaired = attempt();
72
+ const run = fixture();
73
+ await run.handlers.get("before_agent_start")();
74
+ assert.equal((await run.handlers.get("agent_before_settle")(boundary(), ctx)).continue, true);
75
+ await run.tool.execute("repair", { status: "completed", summary: "Recovered existing work", result: "Existing result" });
76
+ assert.equal(await run.handlers.get("agent_before_settle")(boundary(), ctx), undefined);
77
+ assert.equal(JSON.parse(readFileSync(repaired.report, "utf8")).status, "completed");
78
+ console.log("PASS report boundary: zero-request completion, one durable reminder, unconfirmed failure, cancellation/error, idempotency");
79
+ } finally {
80
+ if (previous === undefined) delete process.env.PI_HERDR_SUBAGENTS_TASK_DIR;
81
+ else process.env.PI_HERDR_SUBAGENTS_TASK_DIR = previous;
82
+ rmSync(root, { recursive: true, force: true });
83
+ }
@@ -0,0 +1,69 @@
1
+ // Native Pi lifecycle regression with an offline provider; no Herdr panes or paid requests.
2
+ import "./runtime.mjs";
3
+ import assert from 'node:assert/strict';
4
+ import {randomUUID} from 'node:crypto';
5
+ import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,existsSync,rmSync} from 'node:fs';
6
+ import {join} from 'node:path';
7
+ import {tmpdir} from 'node:os';
8
+ const {createAgentSession,DefaultResourceLoader,ModelRuntime,SessionManager,SettingsManager}=await import('@earendil-works/pi-coding-agent');
9
+ const {createAssistantMessageEventStream}=await import('@earendil-works/pi-ai');
10
+ const {default:childReport}=await import('../extensions/subagent-report.ts');
11
+ // Real Pi lifecycle, offline provider. No Herdr panes, no delegated model, no network.
12
+ delete process.env.HERDR_ENV;delete process.env.HERDR_SOCKET_PATH;
13
+ const root=mkdtempSync(join(tmpdir(),'pi-native-settled-review-'));
14
+ process.env.PI_CODING_AGENT_DIR=root;
15
+ const cases=['reported','failed_report','remind_success','normal_no_report','transient_retry','retry_exhausted','queued_followup','boundary_continue','abort','settled_hook_new_prompt'];
16
+ try{
17
+ for(const scenario of cases){
18
+ const dir=join(root,scenario);mkdirSync(dir);
19
+ const attemptId=randomUUID();const statePath=join(dir,'attempts',attemptId+'.json');
20
+ process.env.PI_HERDR_SUBAGENTS_TASK_DIR=dir;
21
+ writeFileSync(join(dir,'task.json'),JSON.stringify({currentAttemptId:attemptId}));
22
+ const trace=[],states=[],errors=[],inputs=[];let requests=0,ended=0,boundaries=0,settled=0;
23
+ const entered=Promise.withResolvers();let session;
24
+ const runtime=await ModelRuntime.create({authPath:join(dir,'auth.json'),modelsPath:join(dir,'models.json'),refreshOnCreate:false});
25
+ runtime.registerProvider('review',{name:'Offline lifecycle fixture',baseUrl:'http://127.0.0.1:1',api:'review',apiKey:'fixture-not-secret',models:[{id:'offline',name:'Offline',reasoning:false,input:['text'],contextWindow:128000,maxTokens:4096,cost:{input:0,output:0,cacheRead:0,cacheWrite:0}}],
26
+ streamSimple(model,context,options){
27
+ inputs.push(structuredClone(context.messages));requests++;const index=requests;trace.push('request:'+index);entered.resolve();const stream=createAssistantMessageEventStream();
28
+ void(async()=>{
29
+ let reason='stop',error;
30
+ if(scenario==='abort'){if(!options.signal.aborted)await new Promise(r=>options.signal.addEventListener('abort',r,{once:true}));reason='aborted';error='Request was aborted';}
31
+ else if((scenario==='transient_retry'&&index===1)||scenario==='retry_exhausted'){reason='error';error='500 Internal Server Error';}
32
+ const reportNow=scenario==='reported'||scenario==='failed_report'||(scenario==='remind_success'&&index===2);
33
+ const message={role:'assistant',api:model.api,provider:model.provider,model:model.id,timestamp:Date.now(),content:reportNow?[{type:'toolCall',id:'fixture-report',name:'subagent_report',arguments:{status:scenario==='failed_report'?'failed':'completed',summary:'Offline test report',result:'Offline complete result'}}]:reason==='stop'?[{type:'text',text:'A normal final answer without subagent_report'}]:[],stopReason:reportNow?'toolUse':reason,...(error?{errorMessage:error}:{}),usage:{input:1,output:1,cacheRead:0,cacheWrite:0,totalTokens:2,cost:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0}}};
34
+ stream.push({type:'start',partial:message});
35
+ stream.push(reason==='stop'?{type:'done',reason:message.stopReason,message}:{type:'error',reason,error:message});
36
+ })().catch(e=>{console.error(e);process.exitCode=1});return stream;
37
+ }
38
+ });
39
+ const settingsManager=SettingsManager.inMemory({retry:{enabled:true,maxRetries:1,baseDelayMs:1,maxDelayMs:1},compaction:{enabled:false},cacheWarming:'off'});
40
+ const loader=new DefaultResourceLoader({cwd:dir,agentDir:dir,settingsManager,noExtensions:true,noSkills:true,noPromptTemplates:true,noThemes:true,noContextFiles:true,extensionFactories:[childReport,pi=>{
41
+ pi.on('before_agent_start',()=>trace.push('before_agent_start'));
42
+ pi.on('agent_start',()=>trace.push('agent_start'));
43
+ pi.on('agent_end',()=>{trace.push('agent_end');ended++;if(scenario==='queued_followup'&&ended===1)pi.sendUserMessage('Queued followup',{deliverAs:'followUp'});});
44
+ pi.on('agent_before_settle',event=>{trace.push('before_settle:'+event.outcome);boundaries++;if(scenario==='boundary_continue'&&boundaries===1)return {entries:[...event.entries,{type:'custom_message',customType:'review',content:'One continuation',display:false}],continue:true};});
45
+ pi.on('agent_settled',(event,ctx)=>{settled++;trace.push('agent_settled:'+event.aborted);states.push({aborted:event.aborted,idle:ctx.isIdle(),file:JSON.parse(readFileSync(statePath,'utf8')),requests});if(scenario==='settled_hook_new_prompt'&&settled===1)pi.sendUserMessage('New prompt from settlement hook');});
46
+ }]});
47
+ await loader.reload();
48
+ session=(await createAgentSession({cwd:dir,agentDir:dir,modelRuntime:runtime,model:runtime.getModel('review','offline'),resourceLoader:loader,settingsManager,sessionManager:SessionManager.create(dir,dir),tools:['subagent_report']})).session;
49
+ session.extensionRunner.onError(e=>errors.push(e));
50
+ try{
51
+ await session.bindExtensions({});
52
+ const running=session.prompt('Run offline lifecycle check');
53
+ if(scenario==='abort'){await entered.promise;await session.abort();}
54
+ await running;
55
+ assert.equal(errors.length,0,JSON.stringify(errors));
56
+ const expectedRequests={reported:1,failed_report:1,remind_success:2,normal_no_report:2,transient_retry:3,retry_exhausted:2,queued_followup:3,boundary_continue:2,abort:1,settled_hook_new_prompt:3}[scenario];
57
+ assert.equal(requests,expectedRequests,scenario);
58
+ assert.equal(settled,scenario==='settled_hook_new_prompt'?2:1,scenario);
59
+ assert.ok(states.every(s=>s.aborted===(scenario==='abort')));
60
+ const report=JSON.parse(readFileSync(join(dir,'reports',attemptId+'.json'),'utf8'));
61
+ assert.equal(report.status,['reported','remind_success'].includes(scenario)?'completed':'failed');
62
+ if(['abort','retry_exhausted'].includes(scenario))assert.equal(inputs.some(input=>JSON.stringify(input).includes('Do not redo the task')),false);
63
+ if(!['reported','failed_report','remind_success'].includes(scenario))assert.ok(report.documents.some(document=>document.path===session.sessionManager.getSessionFile()));
64
+ if(scenario==='remind_success')assert.ok(JSON.stringify(inputs[1]).includes('Do not redo the task'));
65
+ assert.ok(trace.indexOf('agent_end')<trace.indexOf('before_settle:completed')||['abort','retry_exhausted'].includes(scenario));
66
+ console.log(JSON.stringify({scenario,requests,agentEnds:ended,settledEvents:settled,trace,states:states.map(({file,...s})=>({...s,fileState:file.state})),reportExists:existsSync(join(dir,'reports',attemptId+'.json'))}));
67
+ }finally{await session.abort();await session.extensionRunner.emit({type:'session_shutdown',reason:'quit'});session.dispose();}
68
+ }
69
+ }finally{rmSync(root,{recursive:true,force:true});}
@@ -0,0 +1,26 @@
1
+ // Package-local host-peer resolution so the packed lifecycle test needs no sibling package.
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
4
+ import { registerHooks } from "node:module";
5
+ import { delimiter, dirname, join } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ const hostPath = (process.env.PATH ?? "").split(delimiter).filter((entry) => !entry.endsWith("node_modules/.bin")).join(delimiter);
9
+ const binary = process.env.PI_TEST_BINARY || execFileSync("sh", ["-c", "command -v pi"], {
10
+ encoding: "utf8", env: { ...process.env, PATH: hostPath },
11
+ }).trim();
12
+ let root = dirname(realpathSync(binary));
13
+ while (!existsSync(join(root, "package.json")) || JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name !== "@earendil-works/pi-coding-agent") {
14
+ const parent = dirname(root);
15
+ if (parent === root) throw new Error(`Cannot locate Pi runtime next to ${binary}. Set PI_TEST_BINARY to the installed Pi executable.`);
16
+ root = parent;
17
+ }
18
+ const hostURL = pathToFileURL(join(root, "dist/index.js")).href;
19
+ const peers = new Set(["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai", "@earendil-works/pi-tui", "typebox"]);
20
+ registerHooks({
21
+ resolve(specifier, context, nextResolve) {
22
+ return peers.has(specifier)
23
+ ? nextResolve(specifier, { ...context, parentURL: hostURL })
24
+ : nextResolve(specifier, context);
25
+ },
26
+ });
@@ -0,0 +1,23 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+
7
+ const root = mkdtempSync(join(tmpdir(), "herdr-standalone-"));
8
+ try {
9
+ const source = resolve(import.meta.dirname, "..");
10
+ const [packed] = JSON.parse(execFileSync("npm", [
11
+ "pack", source, "--ignore-scripts", "--json", "--pack-destination", root,
12
+ ], { cwd: root, encoding: "utf8" }));
13
+ execFileSync("tar", ["-xzf", join(root, packed.filename), "-C", root]);
14
+ const output = execFileSync(process.execPath, ["--experimental-strip-types", "tests/report-lifecycle.mjs"], {
15
+ cwd: join(root, "package"), encoding: "utf8", timeout: 30000,
16
+ });
17
+ const results = output.trim().split("\n").map((line) => JSON.parse(line));
18
+ assert.equal(results.length, 10, "all native lifecycle cases must execute outside the workspace");
19
+ assert.ok(results.every((result) => result.reportExists));
20
+ console.log("PASS standalone Herdr tarball: ten native offline lifecycle cases without sibling packages");
21
+ } finally {
22
+ rmSync(root, { recursive: true, force: true });
23
+ }
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Return Git's canonical worktree root for already-active create calls made from a nested directory; preserve the dirty flag.
6
+
3
7
  ## 2026-10-12 - v0.4.0
4
8
 
5
9
  - Replace CLI start and prepare with the create-only command
@@ -72,3 +72,7 @@ pi-worktree prune
72
72
  `create` 是幂等接口;`create --json` 供 Extension 和其他宿主使用,返回 `created`、`attached`、`reused` 或 `already-active`,以及绝对路径、分支和 dirty 状态。已有 worktree 会复用;已存在但未挂载的同名分支会挂载;stale 注册、非法名称和路径冲突会显式失败。
73
73
 
74
74
  Agent 用法见 `skills/pi-worktree/SKILL.md`。测试流程、夹具和结果见 [TESTING.md](TESTING.md)。
75
+
76
+ ## Already-active paths
77
+
78
+ `pi-worktree create --json <current-worktree-name>` returns the canonical Git worktree root, even when called from a nested directory. The `already-active` status and whole-worktree `dirty` flag remain unchanged. The extension uses the same CLI result.
@@ -24,3 +24,7 @@
24
24
  ## 最近验证结果
25
25
 
26
26
  验收要求全部 Node/shell 断言通过,Extension 能被 Pi 单独加载,且 `npm pack --dry-run` 包含 `bin/`、`extensions/`、`skills/` 和 `scripts/install.mjs`。
27
+
28
+ ## Review regressions
29
+
30
+ The CLI integration suite also calls `create --json` from a nested linked-worktree directory. It asserts `already-active`, the canonical worktree root, and the whole-worktree dirty flag. Load the extension with `pi -ne -ns --no-session -e ./packages/worktree/extensions/worktree.ts`; this package has no `load-test` npm script.
@@ -246,7 +246,7 @@ create_wt() {
246
246
  [[ "$current_branch" == "$name" ]] \
247
247
  || die "already inside linked worktree '$current_branch'; return to the main checkout first"
248
248
  CREATE_STATUS="already-active"
249
- CREATE_DIR=$(pwd -P)
249
+ CREATE_DIR=$(cd "$(git rev-parse --show-toplevel)" && pwd -P)
250
250
  CREATE_BRANCH="$current_branch"
251
251
  CREATE_DIRTY=false
252
252
  if [[ -n "$(git status --porcelain)" ]]; then CREATE_DIRTY=true; fi
@@ -95,6 +95,16 @@ node -e '
95
95
  if (value.status !== "already-active" || value.dirty !== true) process.exit(1);
96
96
  ' "$ROOT/active.json"
97
97
 
98
+ mkdir -p .worktrees/prepared/nested/path
99
+ (cd .worktrees/prepared/nested/path && bash "$PACKAGE_DIR/bin/pi-worktree" create --json prepared) > "$ROOT/active-subdir.json"
100
+ node -e '
101
+ const fs = require("node:fs");
102
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
103
+ if (value.status !== "already-active" || value.path !== process.argv[2] || value.dirty !== true) {
104
+ throw new Error("already-active must return the worktree root even when called from a subdirectory");
105
+ }
106
+ ' "$ROOT/active-subdir.json" "$ROOT/repo/.worktrees/prepared"
107
+
98
108
  if bash "$PACKAGE_DIR/bin/pi-worktree" create --json ../escape >/dev/null 2>&1; then
99
109
  echo "create accepted an unsafe worktree name" >&2
100
110
  exit 1
@@ -14,7 +14,7 @@ Minimal CDP tools for collaborative site exploration.
14
14
  ./scripts/start.js --headless # Recommended: isolated reusable profile
15
15
  ./scripts/start.js --headless --profile # Headless with a copy of your profile
16
16
  ./scripts/start.js # Visible browser window when needed
17
- ./scripts/start.js --headless --reset-profile # Clear cached profile before launch
17
+ ./scripts/start.js --headless --reset-profile # Clear a stopped cached profile before launch
18
18
  ```
19
19
 
20
20
  Starts Chrome with remote debugging (default port `:9222`). **Agents should use `--headless` by default** because it is less disruptive and supports navigation, evaluation, screenshots, emulation, and logging. User extensions are disabled for headless launches; a copied profile still provides its cookies and other browser state, but extension-based features are unavailable. Headed launches continue to load extensions normally. Use headed mode only when a person needs to see or interact with the browser, such as for `pick.js`, manual authentication, or debugging a headless-specific difference.
@@ -33,6 +33,8 @@ If Chrome is installed in a non-standard location, set:
33
33
  BROWSER_BIN=/path/to/chrome ./scripts/start.js --headless
34
34
  ```
35
35
 
36
+ `--reset-profile` refuses to delete an active profile. Close Chrome first, including instances on another debugging port. Missing launcher state does not bypass native profile locks; an unverifiable lock fails closed. These checks also run before profile-copy and stale-lock cleanup.
37
+
36
38
  The launcher auto-detects common macOS and Linux paths. Under WSL, it also detects Windows Chrome in the standard `Program Files` locations and launches it through PowerShell with an isolated Windows-local profile.
37
39
 
38
40
  Optional debug endpoint override:
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn, execFileSync, execSync } from "node:child_process";
4
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
- import { homedir } from "node:os";
4
+ import { existsSync, mkdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
5
+ import { homedir, hostname } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
@@ -122,8 +122,8 @@ function isProcessAlive(pid) {
122
122
  try {
123
123
  process.kill(pid, 0);
124
124
  return true;
125
- } catch {
126
- return false;
125
+ } catch (error) {
126
+ return error.code !== "ESRCH";
127
127
  }
128
128
  }
129
129
 
@@ -162,16 +162,16 @@ async function isDebugEndpointUp() {
162
162
 
163
163
  ensureDir(BROWSER_ROOT);
164
164
 
165
- if (resetProfile) {
166
- rmSync(userDataFsDir, { recursive: true, force: true });
167
- }
168
-
169
165
  const state = readState();
170
166
  if (state?.pid && !isProcessAlive(state.pid)) {
171
167
  clearState();
172
168
  }
173
169
 
174
170
  if (await isDebugEndpointUp()) {
171
+ if (resetProfile) {
172
+ console.error(`✗ Cannot reset a profile while debugging endpoint :${DEBUG_PORT} is in use. Close Chrome first.`);
173
+ process.exit(1);
174
+ }
175
175
  const runningState = readState();
176
176
 
177
177
  if (
@@ -211,6 +211,40 @@ if (await isDebugEndpointUp()) {
211
211
  process.exit(1);
212
212
  }
213
213
 
214
+ // A different debug port (or missing state.json) does not mean the profile is
215
+ // unused. Check before reset, profile copy, or removing Chrome's singleton locks.
216
+ try {
217
+ if (state?.userDataDir === userDataDir && state.pid && isProcessAlive(state.pid)) {
218
+ throw new Error("The selected profile is still in use by Chrome");
219
+ }
220
+ if (usesWindowsChrome) {
221
+ const escaped = userDataDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
222
+ const pattern = `--user-data-dir(?:=|\\s+)"?${escaped}(?:"|\\s|$)`;
223
+ const inUse = commandOutput("powershell.exe", [
224
+ "-NoProfile", "-NonInteractive", "-Command",
225
+ `$ErrorActionPreference = 'Stop'; if (Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match ${powershellQuote(pattern)} }) { 'busy' } else { 'free' }`,
226
+ ]);
227
+ if (inUse !== "free") throw new Error("The selected Windows profile is still in use by Chrome");
228
+ } else {
229
+ let lock;
230
+ try { lock = readlinkSync(join(userDataFsDir, "SingletonLock")); }
231
+ catch (error) { if (error.code !== "ENOENT") throw error; }
232
+ if (lock) {
233
+ const match = /^(.*)-(\d+)$/.exec(lock);
234
+ if (!match || match[1] !== hostname() || isProcessAlive(Number(match[2]))) {
235
+ throw new Error("The selected profile has a live or unverifiable Chrome lock");
236
+ }
237
+ }
238
+ }
239
+ } catch (error) {
240
+ console.error(`✗ ${error.message}. Close Chrome before resetting or changing its profile.`);
241
+ process.exit(1);
242
+ }
243
+
244
+ if (resetProfile) {
245
+ rmSync(userDataFsDir, { recursive: true, force: true });
246
+ }
247
+
214
248
  ensureDir(userDataFsDir);
215
249
 
216
250
  if (useProfile) {