@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.
- package/CHANGELOG.md +15 -0
- package/README.md +5 -1
- package/extensions/btw/index.ts +7 -16
- package/extensions/btw/tests/context.mjs +78 -0
- package/package.json +4 -3
- package/packages/cache-export/CHANGELOG.md +11 -0
- package/packages/cache-export/README.md +14 -0
- package/packages/cache-export/TESTING.md +8 -2
- package/packages/cache-export/docs/demo.gif +0 -0
- package/packages/cache-export/docs/screenshot.png +0 -0
- package/packages/cache-export/extensions/cache-export.ts +44 -3
- package/packages/cache-export/package.json +24 -6
- package/packages/cache-export/src/render.ts +120 -59
- package/packages/cache-export/tests/e2e-browser.mjs +45 -5
- package/packages/cache-export/tests/evidence.mjs +113 -0
- package/packages/cache-export/tests/extension.mjs +52 -0
- package/packages/cache-export/tests/run-tests.mjs +12 -11
- package/packages/codex-edit/CHANGELOG.md +7 -0
- package/packages/codex-edit/README.md +4 -0
- package/packages/codex-edit/TESTING.md +4 -0
- package/packages/codex-edit/docs/test-report.html +19 -11
- package/packages/codex-edit/docs/test-report.md +22 -13
- package/packages/codex-edit/docs/test-summary.md +1 -1
- package/packages/codex-edit/extensions/codex-edit.ts +12 -4
- package/packages/codex-edit/tests/comparison-cases.mjs +35 -0
- package/packages/codex-edit/tests/comparison-harness.mjs +5 -6
- package/packages/codex-edit/tests/generate-test-report.mjs +12 -7
- package/packages/codex-edit/tests/native-vs-codex.ts +14 -14
- package/packages/codex-edit/tests/released-vs-local.test.mjs +4 -0
- package/packages/herdr-subagents/ARCHITECTURE.md +4 -1
- package/packages/herdr-subagents/CHANGELOG.md +12 -0
- package/packages/herdr-subagents/README.md +11 -2
- package/packages/herdr-subagents/TESTING.md +12 -0
- package/packages/herdr-subagents/docs/demo.webm +0 -0
- package/packages/herdr-subagents/docs/screenshot.png +0 -0
- package/packages/herdr-subagents/extensions/subagent-report.ts +84 -30
- package/packages/herdr-subagents/package.json +2 -2
- package/packages/herdr-subagents/tests/report-boundary.mjs +83 -0
- package/packages/herdr-subagents/tests/report-lifecycle.mjs +69 -0
- package/packages/herdr-subagents/tests/runtime.mjs +26 -0
- package/packages/herdr-subagents/tests/standalone-package.mjs +23 -0
- package/packages/worktree/CHANGELOG.md +4 -0
- package/packages/worktree/README.md +4 -0
- package/packages/worktree/TESTING.md +4 -0
- package/packages/worktree/bin/pi-worktree +1 -1
- package/packages/worktree/tests/run-tests.sh +10 -0
- package/skills/web-browser/SKILL.md +3 -1
- 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
|
|
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.
|
|
Binary file
|
|
Binary file
|
|
@@ -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("
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
|
@@ -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
|
|
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) {
|