patchwarden 1.6.1 → 1.6.2

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 (91) hide show
  1. package/README.en.md +17 -12
  2. package/README.md +14 -11
  3. package/dist/assessments/assessmentDiagnostics.d.ts +29 -0
  4. package/dist/assessments/assessmentDiagnostics.js +132 -0
  5. package/dist/assessments/assessmentStore.d.ts +39 -0
  6. package/dist/assessments/assessmentStore.js +166 -12
  7. package/dist/assessments/securitySnapshot.d.ts +50 -0
  8. package/dist/assessments/securitySnapshot.js +158 -0
  9. package/dist/control/routes/status.d.ts +14 -0
  10. package/dist/control/routes/status.js +22 -0
  11. package/dist/control/runtime.js +2 -10
  12. package/dist/control/server.js +1 -0
  13. package/dist/diagnostics/allowedTestCommandSafety.d.ts +1 -0
  14. package/dist/diagnostics/allowedTestCommandSafety.js +11 -0
  15. package/dist/direct/directSessionStore.js +5 -4
  16. package/dist/doctor.js +3 -5
  17. package/dist/runner/runTask.d.ts +1 -0
  18. package/dist/runner/runTask.js +47 -6
  19. package/dist/runner/taskRuntime.d.ts +2 -0
  20. package/dist/runner/taskStatusStore.js +12 -1
  21. package/dist/runner/watch.js +64 -1
  22. package/dist/smoke-test.js +3 -3
  23. package/dist/tools/definitions/toolDefs.js +3 -3
  24. package/dist/tools/diagnostics/auditTask.d.ts +1 -0
  25. package/dist/tools/diagnostics/auditTask.js +35 -7
  26. package/dist/tools/diagnostics/healthCheck.d.ts +12 -0
  27. package/dist/tools/diagnostics/healthCheck.js +29 -1
  28. package/dist/tools/tasks/cancelTask.js +2 -1
  29. package/dist/tools/tasks/createTask.d.ts +2 -2
  30. package/dist/tools/tasks/createTask.js +70 -9
  31. package/dist/tools/tasks/diagnoseTask.js +4 -8
  32. package/dist/tools/tasks/getTaskStatus.js +6 -1
  33. package/dist/tools/tasks/getTaskSummary.js +2 -10
  34. package/dist/tools/tasks/listTasks.js +2 -1
  35. package/dist/tools/tasks/reconcileTasks.d.ts +2 -1
  36. package/dist/tools/tasks/reconcileTasks.js +185 -39
  37. package/dist/tools/tasks/runTaskLoop.js +5 -11
  38. package/dist/tools/tasks/taskOutputs.d.ts +2 -2
  39. package/dist/tools/tasks/taskStates.d.ts +6 -0
  40. package/dist/tools/tasks/taskStates.js +52 -0
  41. package/dist/tools/tasks/waitForTask.js +3 -11
  42. package/dist/tools/workspace/listAgents.d.ts +6 -0
  43. package/dist/tools/workspace/listAgents.js +12 -2
  44. package/dist/utils/atomicFile.js +20 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/docs/CODE_WIKI.md +4 -4
  48. package/docs/open-source-application.md +11 -12
  49. package/docs/release-evidence.md +33 -47
  50. package/package.json +1 -1
  51. package/scripts/checks/control-center-smoke.js +11 -4
  52. package/scripts/checks/lifecycle-smoke.js +5 -2
  53. package/scripts/checks/mcp-smoke.js +31 -1
  54. package/scripts/checks/watcher-supervisor-smoke.js +29 -1
  55. package/scripts/control/start-patchwarden-tunnel.ps1 +65 -1
  56. package/scripts/e2e/demo-runtime.mjs +153 -0
  57. package/scripts/release/desktop-preflight.js +1 -1
  58. package/src/assessments/assessmentDiagnostics.ts +172 -0
  59. package/src/assessments/assessmentStore.ts +237 -12
  60. package/src/assessments/securitySnapshot.ts +231 -0
  61. package/src/control/routes/status.ts +37 -0
  62. package/src/control/runtime.ts +2 -10
  63. package/src/control/server.ts +1 -0
  64. package/src/diagnostics/allowedTestCommandSafety.ts +12 -0
  65. package/src/direct/directSessionStore.ts +6 -4
  66. package/src/doctor.ts +3 -5
  67. package/src/runner/runTask.ts +59 -6
  68. package/src/runner/taskRuntime.ts +2 -0
  69. package/src/runner/taskStatusStore.ts +16 -1
  70. package/src/runner/watch.ts +67 -1
  71. package/src/smoke-test.ts +2 -2
  72. package/src/tools/definitions/toolDefs.ts +3 -3
  73. package/src/tools/diagnostics/auditTask.ts +37 -7
  74. package/src/tools/diagnostics/healthCheck.ts +29 -1
  75. package/src/tools/tasks/cancelTask.ts +2 -1
  76. package/src/tools/tasks/createTask.ts +113 -8
  77. package/src/tools/tasks/diagnoseTask.ts +4 -8
  78. package/src/tools/tasks/getTaskStatus.ts +6 -1
  79. package/src/tools/tasks/getTaskSummary.ts +2 -11
  80. package/src/tools/tasks/listTasks.ts +2 -1
  81. package/src/tools/tasks/reconcileTasks.ts +162 -13
  82. package/src/tools/tasks/runTaskLoop.ts +4 -12
  83. package/src/tools/tasks/taskStates.ts +54 -0
  84. package/src/tools/tasks/waitForTask.ts +3 -12
  85. package/src/tools/workspace/listAgents.ts +17 -3
  86. package/src/utils/atomicFile.ts +17 -1
  87. package/src/version.ts +1 -1
  88. package/ui/pages/dashboard.html +4 -0
  89. package/ui/pages/logs.html +29 -0
  90. package/ui/pages/settings.html +3 -3
  91. package/ui/settings.js +26 -17
@@ -56,24 +56,24 @@ evidence packs.
56
56
 
57
57
  ## Current Public Evidence
58
58
 
59
- Remote snapshot checked on 2026-07-19:
59
+ Remote snapshot checked on 2026-07-23:
60
60
 
61
61
  - GitHub repository: `jiezeng2004-design/PatchWarden`
62
62
  - Stars: 2
63
63
  - Forks: 0
64
- - Open issues: 9
65
- - Open pull requests: 4
66
- - GitHub latest release: `v1.6.0`, published on 2026-07-16
67
- - npm latest dist-tag: `patchwarden@1.5.1`
64
+ - Open issues: 5
65
+ - Open pull requests: 1
66
+ - GitHub latest release: `v1.6.1`, published on 2026-07-23
67
+ - npm latest dist-tag: `patchwarden@1.6.1`
68
68
 
69
69
  Local workspace facts, kept separate from the public snapshot:
70
70
 
71
71
  - Local source version in `package.json`: `1.6.1`
72
- - Local working branch: `codex/windows-desktop-tunnel-fixes`
72
+ - Verified release commit: `2ab2b7405d89b12c4ef10febf56404de59c04053`
73
73
 
74
- Local source is `v1.6.1`; the 2026-07-19 remote snapshot still records GitHub
75
- Release `v1.6.0` and npm `latest` `1.5.1`. Treat the publication surfaces as out
76
- of sync; local builds do not establish that either remote surface was updated.
74
+ The local source, GitHub Release, and npm `latest` are synchronized at `v1.6.1`.
75
+ The GitHub Release contains reviewed Core and Windows Desktop assets plus
76
+ SHA-256 manifests.
77
77
 
78
78
  ## Existing Project Evidence
79
79
 
@@ -117,9 +117,8 @@ Known gaps before submission:
117
117
  - collect 3 to 5 real user reports through GitHub issues or discussions
118
118
  - record a live screencast if stronger evidence is needed; a privacy-safe
119
119
  scripted GIF now exists for the public maintainer workflow
120
- - resolve the release lag through the reviewed workflow; local source is
121
- `1.6.1`, while the last verified GitHub Release is `1.6.0` and npm `latest`
122
- / `dist-tags.latest` are `1.5.1`
120
+ - keep future source, GitHub Release, and npm publication facts synchronized
121
+ through the reviewed release workflow
123
122
  - add small contributor-friendly roadmap issues such as macOS docs, Codex CLI
124
123
  examples, and a minimal demo repository
125
124
 
@@ -5,28 +5,24 @@ before submitting external applications or publishing a release.
5
5
 
6
6
  ## Current Snapshot
7
7
 
8
- Remote facts checked on 2026-07-19. Local source and branch values were read
9
- from the PatchWarden workspace and are not evidence that a public package was
10
- published.
8
+ Remote facts checked on 2026-07-23 after the v1.6.1 release completed.
11
9
 
12
10
  | Surface | Current evidence |
13
11
  | --- | --- |
14
- | Local branch | `codex/windows-desktop-tunnel-fixes` |
12
+ | Verified merge commit | `2ab2b7405d89b12c4ef10febf56404de59c04053` |
15
13
  | Local `package.json` | `patchwarden@1.6.1` |
16
- | GitHub latest release | `v1.6.0` |
17
- | GitHub release URL | https://github.com/jiezeng2004-design/PatchWarden/releases/tag/v1.6.0 |
18
- | GitHub release published on | 2026-07-16 |
19
- | npm latest | `patchwarden@1.5.1` |
20
- | npm `dist-tags.latest` | `1.5.1` |
14
+ | GitHub latest release | `v1.6.1` |
15
+ | GitHub release URL | https://github.com/jiezeng2004-design/PatchWarden/releases/tag/v1.6.1 |
16
+ | GitHub release published on | 2026-07-23 |
17
+ | npm latest | `patchwarden@1.6.1` |
18
+ | npm `dist-tags.latest` | `1.6.1` |
21
19
  | GitHub stars | 2 |
22
20
  | GitHub forks | 0 |
23
- | Open issues | 9 |
24
- | Open pull requests | 4 |
21
+ | Open issues | 5 |
22
+ | Open pull requests | 1 |
25
23
 
26
- Conclusion: local source is `v1.6.1`; the last verified GitHub Release is
27
- `v1.6.0`, while npm `latest` remains `1.5.1`. Do not describe the public
28
- release surfaces as synchronized until GitHub, npm, and `dist-tags.latest` are
29
- independently verified at the intended version.
24
+ Conclusion: source, Git tag, GitHub Release, npm package version, and
25
+ `dist-tags.latest` are independently verified at `v1.6.1`.
30
26
 
31
27
  ## Commands Used
32
28
 
@@ -34,49 +30,39 @@ Windows PowerShell:
34
30
 
35
31
  ```powershell
36
32
  gh repo view jiezeng2004-design/PatchWarden --json nameWithOwner,stargazerCount,forkCount,issues,pullRequests,defaultBranchRef,pushedAt,url,description,licenseInfo,repositoryTopics,latestRelease,hasDiscussionsEnabled,hasIssuesEnabled,securityPolicyUrl
37
- gh release view v1.6.0 --repo jiezeng2004-design/PatchWarden --json tagName,name,publishedAt,isDraft,isPrerelease,url
33
+ gh release view v1.6.1 --repo jiezeng2004-design/PatchWarden --json tagName,name,publishedAt,isDraft,isPrerelease,url
38
34
  gh release view --repo jiezeng2004-design/PatchWarden --json tagName,name,publishedAt,isDraft,isPrerelease,url
39
35
  npm.cmd view patchwarden version dist-tags --json --cache .\.npm-cache
40
36
  ```
41
37
 
42
- The npm query was verified on 2026-07-19: `version=1.5.1` and
43
- `dist-tags.latest=1.5.1`.
38
+ The npm query was verified on 2026-07-23: `version=1.6.1` and
39
+ `dist-tags.latest=1.6.1`.
44
40
 
45
- ## Local Verification Snapshot (Separate From Remote Truth)
41
+ ## v1.6.1 Verification Snapshot
46
42
 
47
- The following 2026-07-19 baseline checks describe the local working tree only.
48
- They do not prove GitHub/npm publication state, and the complete gate must be
49
- rerun after the current optimization work finishes.
50
-
51
- - Root TypeScript `--noEmit` and the Desktop TypeScript build: passed.
52
- - Targeted reliability/security tests: 49 passed; targeted Desktop backend
53
- lifecycle tests: 8 passed.
54
- - `npm.cmd test`: passed on the second baseline run, including 139/139 security
55
- checks, 747 passed and 2 skipped unit tests, 22/22 lifecycle checks, and
56
- 32/32 Control Center checks. An earlier run exposed one Git commit setup
57
- flake; the test setup was then hardened.
58
- - `npm.cmd run test:mcp`: passed.
59
- - `npm.cmd run test:http-mcp`: 13 passed, 0 failed.
60
- - `npm.cmd run desktop:test`: 41 passed, 0 failed at the baseline snapshot.
43
+ - GitHub CI: Node.js 18, Ubuntu Node.js 20, Windows Node.js 20, secret scan,
44
+ Windows Desktop installer, and final CI gate passed.
45
+ - Core security smoke: 141/141; unit tests: 855 passed and 3 skipped;
46
+ lifecycle: 22/22; HTTP MCP: 17/17; MCP smoke and Doctor passed.
47
+ - Desktop tests: 54/54; clean-source Desktop preflight and packaged UI smoke
48
+ passed from the verified merge commit.
61
49
  - Root and Desktop dependency audits: 0 known vulnerabilities.
62
- - `npm.cmd run pack:clean` was not run for this historical baseline. Current
63
- versions recreate only `release/package/` plus the two root package archives;
64
- Desktop and preflight siblings under `release/` are preserved. It still
65
- overwrites those package artifacts, so run it only during explicitly approved
66
- release preparation.
50
+ - Clean release directory: 532 files; npm package manifest: 531 files.
51
+ - GitHub Release: six reviewed assets with GitHub-computed and local SHA-256
52
+ digests; installer signature status is explicitly documented as unsigned.
67
53
 
68
54
  ## Release Verification Checklist
69
55
 
70
- - [ ] Confirm the target version in `package.json`.
71
- - [ ] Confirm `src/version.ts`, package metadata, README version text, and
56
+ - [x] Confirm the target version in `package.json`.
57
+ - [x] Confirm `src/version.ts`, package metadata, README version text, and
72
58
  changelog agree.
73
- - [ ] Run the complete local gate chain from `AGENTS.md`.
74
- - [ ] Open a PR and wait for the GitHub CI gate.
75
- - [ ] Merge only after review.
76
- - [ ] Create the tag from the verified merge commit.
77
- - [ ] Create the GitHub Release with reviewed artifacts and checksums.
78
- - [ ] Publish `patchwarden` to npm using process-scoped authentication.
79
- - [ ] Verify the remote tag, GitHub Release, npm package version, and
59
+ - [x] Run the complete local gate chain from `AGENTS.md`.
60
+ - [x] Open a PR and wait for the GitHub CI gate.
61
+ - [x] Merge only after review.
62
+ - [x] Create the tag from the verified merge commit.
63
+ - [x] Create the GitHub Release with reviewed artifacts and checksums.
64
+ - [x] Publish `patchwarden` to npm using process-scoped authentication.
65
+ - [x] Verify the remote tag, GitHub Release, npm package version, and
80
66
  `dist-tags.latest`.
81
67
 
82
68
  Do not publish new versions under the frozen pre-rename package name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchwarden",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "Secure local MCP bridge for ChatGPT and local agents, with workspace-scoped task loops, safe summaries, audits, and Direct editing.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -251,6 +251,7 @@ async function testStaticFiles() {
251
251
  const css = await httpGet(`${BASE_URL}/colors_and_type.css`);
252
252
  const desktopCss = await httpGet(`${BASE_URL}/desktop.css`);
253
253
  const desktopBridge = await httpGet(`${BASE_URL}/desktop-bridge.js`);
254
+ const logParser = await httpGet(`${BASE_URL}/log-parser.js`);
254
255
  const i18n = await httpGet(`${BASE_URL}/i18n.js`);
255
256
  const gettingStarted = await httpGet(`${BASE_URL}/pages/getting-started.html`);
256
257
  const gettingStartedJs = await httpGet(`${BASE_URL}/getting-started.js`);
@@ -280,6 +281,9 @@ async function testStaticFiles() {
280
281
  if (desktopBridge.status !== 200 || !(desktopBridge.headers["content-type"] || "").includes("javascript")) {
281
282
  checks.push("desktop bridge is not served as JavaScript");
282
283
  }
284
+ if (logParser.status !== 200 || !(logParser.headers["content-type"] || "").includes("javascript") || !logParser.body.includes("PatchWardenLogParser")) {
285
+ checks.push("log parser is not served as JavaScript");
286
+ }
283
287
  if (i18n.status !== 200 || !i18n.body.includes("applyTranslations") || !i18n.body.includes('"zh-CN"') || !i18n.body.includes("en:")) {
284
288
  checks.push("shared Chinese/English translation dictionary is unavailable");
285
289
  }
@@ -940,9 +944,9 @@ async function testReconcileNoToken() {
940
944
  }
941
945
  }
942
946
 
943
- // ── Test 14: GET /api/direct-sessions returns empty list (not 500)
947
+ // ── Test 14: GET /api/direct-sessions returns a bounded list (not 500)
944
948
  async function testDirectSessionsEmptyList() {
945
- const name = "Test 14: /api/direct-sessions returns empty list (not 500) when dir missing";
949
+ const name = "Test 14: /api/direct-sessions returns a bounded list (not 500)";
946
950
  try {
947
951
  const res = await httpGet(`${BASE_URL}/api/direct-sessions`);
948
952
  if (res.status !== 200) {
@@ -961,8 +965,11 @@ async function testDirectSessionsEmptyList() {
961
965
  if (!isObject(json.filters) || !isObject(json.filters.options)) problems.push("'filters' contract is missing");
962
966
  if (typeof json.direct_profile_enabled !== "boolean") problems.push("'direct_profile_enabled' is not a boolean");
963
967
  if (json.reason !== null && typeof json.reason !== "string") problems.push("'reason' is neither null nor string");
964
- if (Array.isArray(json.sessions) && json.sessions.length !== json.total) {
965
- problems.push(`sessions.length (${json.sessions.length}) != total (${json.total})`);
968
+ if (Array.isArray(json.sessions) && json.sessions.length > json.total) {
969
+ problems.push(`sessions.length (${json.sessions.length}) exceeds total (${json.total})`);
970
+ }
971
+ if (Array.isArray(json.sessions) && json.sessions.length < json.total && typeof json.nextCursor !== "string") {
972
+ problems.push("paginated direct sessions response is missing nextCursor");
966
973
  }
967
974
  const invalidDetail = await httpGet(`${BASE_URL}/api/direct-sessions/invalid.id`);
968
975
  const invalidSummary = await httpGet(`${BASE_URL}/api/direct-sessions/invalid.id/summary`);
@@ -170,6 +170,9 @@ try {
170
170
  if (result.total !== 8 || result.agents.some((agent) => !agent.available)) {
171
171
  throw new Error(`Unexpected agent availability: ${JSON.stringify(result)}`);
172
172
  }
173
+ if (result.agents.some((agent) => agent.availability_scope !== "executable_only" || agent.provider_status !== "not_checked")) {
174
+ throw new Error(`Agent readiness scope was not explicit: ${JSON.stringify(result)}`);
175
+ }
173
176
  });
174
177
 
175
178
  await test("git diff captures tracked and untracked task changes", async () => {
@@ -803,12 +806,12 @@ try {
803
806
  const task = await createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 1 });
804
807
  const started = Date.now();
805
808
  const result = await runTask(task.task_id);
806
- if (result.status !== "failed" || !result.error?.includes("timed out")) {
809
+ if (result.status !== "timeout" || !result.error?.includes("timed out")) {
807
810
  throw new Error(`Expected timeout failure, got ${JSON.stringify(result)}`);
808
811
  }
809
812
  if (Date.now() - started > 10000) throw new Error("Timeout took too long to stop the process");
810
813
  const status = getTaskStatus(task.task_id);
811
- if (status.phase !== "failed" || !status.last_heartbeat_at) {
814
+ if (status.phase !== "timeout" || status.termination_reason !== "timeout" || !status.last_heartbeat_at) {
812
815
  throw new Error(`Missing timeout phase/heartbeat: ${JSON.stringify(status)}`);
813
816
  }
814
817
  if (!existsSync(join(task.path, "progress.md"))) throw new Error("progress.md missing");
@@ -14,7 +14,10 @@ import { fileURLToPath } from "node:url";
14
14
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
15
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
16
16
 
17
- const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
17
+ const sourceRoot = resolve(fileURLToPath(new URL("../..", import.meta.url)));
18
+ const root = process.env.PATCHWARDEN_TEST_RUNTIME_ROOT
19
+ ? resolve(process.env.PATCHWARDEN_TEST_RUNTIME_ROOT)
20
+ : sourceRoot;
18
21
  const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
19
22
  const expectedServerVersion = packageJson.version;
20
23
  const tempRoot = mkdtempSync(join(tmpdir(), "patchwarden-mcp-"));
@@ -292,6 +295,33 @@ try {
292
295
  }
293
296
  ok("create_task accepts inline plans and guarded templates");
294
297
 
298
+ const assessment = await parseToolJson("create_task", {
299
+ execution_mode: "assess_only",
300
+ template: "inspect_only",
301
+ goal: "Inspect package metadata without changing files.",
302
+ agent: "codex",
303
+ repo_path: ".",
304
+ verify_commands: [],
305
+ scope: ["package.json"],
306
+ forbidden: ["release/**"],
307
+ });
308
+ const assessmentFile = join(workspaceRoot, ".patchwarden", "assessments", assessment.assessment_id, "assessment.json");
309
+ const assessmentRecord = JSON.parse(readFileSync(assessmentFile, "utf-8"));
310
+ if (
311
+ !/^[a-f0-9]{64}$/.test(assessmentRecord.execution_config_hash || "") ||
312
+ assessmentRecord.assessment_security_snapshot_version !== "assessment-security-v2"
313
+ ) {
314
+ throw new Error(`assessment security fingerprint missing: ${JSON.stringify(assessmentRecord)}`);
315
+ }
316
+ const assessedTask = await parseToolJson("create_task", {
317
+ execution_mode: "execute",
318
+ assessment_id: assessment.assessment_id,
319
+ });
320
+ if (assessedTask.status !== "pending" || !assessedTask.task_id) {
321
+ throw new Error(`two-stage assessment did not create a pending task: ${JSON.stringify(assessedTask)}`);
322
+ }
323
+ ok("assess_only then execute preserves the versioned security fingerprint");
324
+
295
325
  const blockedAgent = await client.callTool({
296
326
  name: "create_task",
297
327
  arguments: { plan_id: plan.plan_id, agent: "missing-agent", repo_path: "." },
@@ -36,8 +36,9 @@ try {
36
36
  }
37
37
  });
38
38
 
39
+ await runReusedPidLockScenario();
39
40
  await runExternalScenario();
40
- console.log("ok - watcher supervisor isolates environment and handles exit, stale heartbeat, retry limit, and external ownership");
41
+ console.log("ok - watcher supervisor isolates environment and handles exit, stale heartbeat, PID reuse, retry limit, and external ownership");
41
42
  } finally {
42
43
  for (const child of children) {
43
44
  if (child.exitCode === null) child.kill("SIGKILL");
@@ -45,6 +46,32 @@ try {
45
46
  rmSync(temp, { recursive: true, force: true });
46
47
  }
47
48
 
49
+ async function runReusedPidLockScenario() {
50
+ const fixture = createFixture("reused-pid-lock");
51
+ mkdirSync(dirname(fixture.lockPath), { recursive: true });
52
+ writeFileSync(fixture.lockPath, JSON.stringify({
53
+ pid: process.pid,
54
+ instance_id: "stale-reused-pid",
55
+ launcher_pid: 999999,
56
+ started_at: "2026-01-01T00:00:00.000Z",
57
+ }), "utf-8");
58
+ writeFileSync(fixture.heartbeatPath, JSON.stringify({
59
+ status: "running",
60
+ pid: process.pid,
61
+ instance_id: "stale-reused-pid",
62
+ launcher_pid: 999999,
63
+ started_at: "2026-01-01T00:00:00.000Z",
64
+ last_heartbeat_at: "2026-01-01T00:00:00.000Z",
65
+ }), "utf-8");
66
+
67
+ const result = runLauncher(fixture, "healthy", 1, 7_000);
68
+ if (result.error && result.error.code === "ETIMEDOUT") throw result.error;
69
+ const attempts = Number(readFileSync(fixture.watcherAttemptPath, "utf-8")) || 0;
70
+ if (attempts < 1 || !result.stdout.includes("Removed stale watcher lock after PID identity verification")) {
71
+ throw new Error(`Reused PID lock was not safely retired: ${JSON.stringify({ attempts, stdout: result.stdout, stderr: result.stderr })}`);
72
+ }
73
+ }
74
+
48
75
  async function runScenario(label, mode, maxRestarts, lifetimeMs, assertState) {
49
76
  const fixture = createFixture(label.replace(/[^a-z0-9]+/gi, "-"));
50
77
  const result = runLauncher(fixture, mode, maxRestarts, lifetimeMs);
@@ -139,6 +166,7 @@ function createFixture(name) {
139
166
  watcherPath,
140
167
  watcherAttemptPath,
141
168
  heartbeatPath: join(workspace, ".patchwarden", "watcher-heartbeat.json"),
169
+ lockPath: join(workspace, ".patchwarden", "watcher.lock"),
142
170
  watcherStatusPath: join(localAppData, "patchwarden", "runtime", "watcher-status.json"),
143
171
  mockCmd,
144
172
  localAppData,
@@ -80,6 +80,15 @@ if (-not $ConfigPath) {
80
80
  }
81
81
  $McpLauncherName = if ($ToolProfile -eq "chatgpt_direct") { "patchwarden-mcp-direct.cmd" } else { "patchwarden-mcp-stdio.cmd" }
82
82
  $McpStdioLauncher = Join-Path $ProjectRoot "scripts\mcp\$McpLauncherName"
83
+ $McpEntryPath = Join-Path $ProjectRoot "dist\index.js"
84
+ $WatcherEntryPath = Join-Path $ProjectRoot "dist\runner\watch.js"
85
+ $RuntimeBuildLayout = if (Test-Path -LiteralPath (Join-Path $ProjectRoot ".git")) {
86
+ "git_worktree"
87
+ } elseif ($ProjectRoot -match '(?i)[\\/]resources[\\/]core$') {
88
+ "desktop_packaged_core"
89
+ } else {
90
+ "standalone_build"
91
+ }
83
92
  $McpStdioLauncherForTunnel = $McpStdioLauncher -replace "\\", "/"
84
93
  $OpencodeConfigHome = Join-Path $env:LOCALAPPDATA "patchwarden\opencode-config"
85
94
  $ProfilePath = Join-Path $env:APPDATA "tunnel-client\$Profile.yaml"
@@ -243,6 +252,14 @@ function Get-LastDiagnosticLine {
243
252
  return $Fallback
244
253
  }
245
254
 
255
+ function Get-MaskedTunnelId {
256
+ param([string]$Value)
257
+ if ([string]::IsNullOrWhiteSpace($Value)) { return $null }
258
+ $trimmed = $Value.Trim()
259
+ if ($trimmed.Length -le 8) { return "***" }
260
+ return "$($trimmed.Substring(0, 4))***$($trimmed.Substring($trimmed.Length - 4))"
261
+ }
262
+
246
263
  function Write-TunnelStatus {
247
264
  param(
248
265
  [string]$Status,
@@ -263,6 +280,8 @@ function Write-TunnelStatus {
263
280
  status = $Status
264
281
  reason_code = $ReasonCode
265
282
  ready = $Ready
283
+ profile = $Profile
284
+ tunnel_id_masked = Get-MaskedTunnelId -Value $TunnelId
266
285
  attempt = $Attempt
267
286
  pid = $ProcessId
268
287
  checked_at = (Get-Date).ToUniversalTime().ToString("o")
@@ -281,6 +300,12 @@ function Write-TunnelStatus {
281
300
  tool_manifest_sha256 = if ($script:ToolManifest) { $script:ToolManifest.tool_manifest_sha256 } else { $null }
282
301
  tools_ready = [bool]($script:ToolManifest -and $script:ToolManifest.ok)
283
302
  core_tools_ready = [bool]($ToolProfile -eq "chatgpt_core" -and $script:ToolManifest -and $script:ToolManifest.ok)
303
+ project_root = $ProjectRoot
304
+ config_path = $ConfigPath
305
+ mcp_entry_path = $McpEntryPath
306
+ watcher_entry_path = $WatcherEntryPath
307
+ validator_module_path = (Join-Path $ProjectRoot "dist\assessments\assessmentStore.js")
308
+ runtime_build_layout = $RuntimeBuildLayout
284
309
  }
285
310
  $temporary = "$StatusFile.tmp"
286
311
  $payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8
@@ -344,6 +369,30 @@ function Get-WatcherHeartbeatState {
344
369
  }
345
370
  }
346
371
 
372
+ function Repair-StaleWatcherLock {
373
+ $settings = Get-WatcherSettings
374
+ $lockPath = Join-Path (Split-Path -Parent $settings.HeartbeatPath) "watcher.lock"
375
+ if (-not (Test-Path -LiteralPath $lockPath)) { return "not_present" }
376
+ try {
377
+ $lock = Get-Content -LiteralPath $lockPath -Raw -Encoding UTF8 | ConvertFrom-Json
378
+ $lockPid = [int]$lock.pid
379
+ if ($lockPid -le 0) { return "unverified" }
380
+ $candidate = Get-CimInstance Win32_Process -Filter "ProcessId = $lockPid" -ErrorAction Stop
381
+ if ($candidate) {
382
+ $normalizedCommand = ([string]$candidate.CommandLine).Replace("/", "\")
383
+ $normalizedExpected = ([System.IO.Path]::GetFullPath($WatcherEntryPath)).Replace("/", "\")
384
+ $isExpectedWatcher = [string]$candidate.Name -ieq "node.exe" -and
385
+ $normalizedCommand.IndexOf($normalizedExpected, [StringComparison]::OrdinalIgnoreCase) -ge 0
386
+ if ($isExpectedWatcher) { return "expected_watcher_alive" }
387
+ }
388
+ Remove-Item -LiteralPath $lockPath -Force -ErrorAction Stop
389
+ Write-Host "[watch] Removed stale watcher lock after PID identity verification."
390
+ return "removed"
391
+ } catch {
392
+ return "unverified"
393
+ }
394
+ }
395
+
347
396
  function Write-WatcherStatus {
348
397
  param([string]$Status, [string]$LastError = $null)
349
398
  New-Item -ItemType Directory -Force -Path $RuntimeDirectory | Out-Null
@@ -356,6 +405,11 @@ function Write-WatcherStatus {
356
405
  restart_attempts = $script:WatcherRestartAttempts
357
406
  checked_at = (Get-Date).ToUniversalTime().ToString("o")
358
407
  last_error = if ($LastError) { ($LastError -replace '[\r\n]+', ' ').Substring(0, [Math]::Min(500, ($LastError -replace '[\r\n]+', ' ').Length)) } else { $null }
408
+ project_root = $ProjectRoot
409
+ config_path = $ConfigPath
410
+ watcher_entry_path = $WatcherEntryPath
411
+ validator_module_path = (Join-Path $ProjectRoot "dist\assessments\assessmentStore.js")
412
+ runtime_build_layout = $RuntimeBuildLayout
359
413
  }
360
414
  $temporary = "$WatcherStatusFile.tmp"
361
415
  $payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8
@@ -379,6 +433,7 @@ function Start-OwnedWatcherProcess {
379
433
  $script:WatcherHealthySince = $null
380
434
  $previousWatcherInstanceId = $env:PATCHWARDEN_WATCHER_INSTANCE_ID
381
435
  $previousWatcherLauncherPid = $env:PATCHWARDEN_WATCHER_LAUNCHER_PID
436
+ $previousToolProfile = $env:PATCHWARDEN_TOOL_PROFILE
382
437
  $previousXdgConfigHome = $env:XDG_CONFIG_HOME
383
438
  $previousPath = $env:PATH
384
439
  $previousControlPlaneApiKey = $env:CONTROL_PLANE_API_KEY
@@ -386,17 +441,19 @@ function Start-OwnedWatcherProcess {
386
441
  try {
387
442
  $env:PATCHWARDEN_WATCHER_INSTANCE_ID = $script:WatcherInstanceId
388
443
  $env:PATCHWARDEN_WATCHER_LAUNCHER_PID = [string]$PID
444
+ $env:PATCHWARDEN_TOOL_PROFILE = $ToolProfile
389
445
  $env:XDG_CONFIG_HOME = $OpencodeConfigHome
390
446
  if ($OpencodeBin) { $env:PATH = "$OpencodeBin;$env:PATH" }
391
447
  Remove-Item Env:CONTROL_PLANE_API_KEY -ErrorAction SilentlyContinue
392
448
  Remove-Item Env:PATCHWARDEN_OWNER_TOKEN -ErrorAction SilentlyContinue
393
449
  $script:WatcherProcess = Start-Process -FilePath $node `
394
- -ArgumentList @((Join-Path $ProjectRoot "dist\runner\watch.js")) `
450
+ -ArgumentList @($WatcherEntryPath) `
395
451
  -WorkingDirectory $ProjectRoot -PassThru -WindowStyle Hidden `
396
452
  -RedirectStandardOutput $stdout -RedirectStandardError $stderr
397
453
  } finally {
398
454
  if ($null -eq $previousWatcherInstanceId) { Remove-Item Env:PATCHWARDEN_WATCHER_INSTANCE_ID -ErrorAction SilentlyContinue } else { $env:PATCHWARDEN_WATCHER_INSTANCE_ID = $previousWatcherInstanceId }
399
455
  if ($null -eq $previousWatcherLauncherPid) { Remove-Item Env:PATCHWARDEN_WATCHER_LAUNCHER_PID -ErrorAction SilentlyContinue } else { $env:PATCHWARDEN_WATCHER_LAUNCHER_PID = $previousWatcherLauncherPid }
456
+ if ($null -eq $previousToolProfile) { Remove-Item Env:PATCHWARDEN_TOOL_PROFILE -ErrorAction SilentlyContinue } else { $env:PATCHWARDEN_TOOL_PROFILE = $previousToolProfile }
400
457
  if ($null -eq $previousXdgConfigHome) { Remove-Item Env:XDG_CONFIG_HOME -ErrorAction SilentlyContinue } else { $env:XDG_CONFIG_HOME = $previousXdgConfigHome }
401
458
  if ($null -eq $previousControlPlaneApiKey) { Remove-Item Env:CONTROL_PLANE_API_KEY -ErrorAction SilentlyContinue } else { $env:CONTROL_PLANE_API_KEY = $previousControlPlaneApiKey }
402
459
  if ($null -eq $previousOwnerToken) { Remove-Item Env:PATCHWARDEN_OWNER_TOKEN -ErrorAction SilentlyContinue } else { $env:PATCHWARDEN_OWNER_TOKEN = $previousOwnerToken }
@@ -414,6 +471,13 @@ function Start-PatchWardenWatcher {
414
471
  Write-Host "[watch] Existing watcher heartbeat is fresh; it remains external and will not be managed."
415
472
  return
416
473
  }
474
+ $lockRepair = Repair-StaleWatcherLock
475
+ if ($lockRepair -eq "expected_watcher_alive" -or $lockRepair -eq "unverified") {
476
+ $script:WatcherManaged = $false
477
+ Write-WatcherStatus -Status "external_stale" -LastError "A stale watcher lock could not be safely retired."
478
+ Write-Host "[watch] Stale watcher lock retained because process ownership could not be disproved."
479
+ return
480
+ }
417
481
  Start-OwnedWatcherProcess
418
482
  }
419
483
 
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+
7
+ const runtimeRoot = resolve(process.env.PATCHWARDEN_RUNTIME_ROOT || join(import.meta.dirname, "..", "..", "release", "desktop", "win-unpacked", "resources", "core"));
8
+ const configPath = resolve(process.env.PATCHWARDEN_CONFIG || join(process.env.LOCALAPPDATA || "", "PatchWarden", "patchwarden.config.json"));
9
+ const fixture = resolve(process.env.PATCHWARDEN_DEMO_FIXTURE || join(process.env.PATCHWARDEN_WORKSPACE || "D:/ai_agent/patchwarden_program", "patchwarden-demo-fixture"));
10
+ const evidencePath = resolve(process.env.PATCHWARDEN_DEMO_EVIDENCE || join(process.env.PATCHWARDEN_WORKSPACE || "D:/ai_agent/patchwarden_program", ".patchwarden", "demo-runtime-evidence.json"));
11
+ const mode = process.argv[2] || "core-prepare";
12
+ const profile = mode === "direct" ? "chatgpt_direct" : "chatgpt_core";
13
+
14
+ function decode(result) {
15
+ if (result?.isError) {
16
+ const message = result.content?.find((item) => item.type === "text")?.text || "MCP tool error";
17
+ throw new Error(message);
18
+ }
19
+ const text = result?.content?.find((item) => item.type === "text")?.text;
20
+ if (typeof text !== "string") return result;
21
+ try { return JSON.parse(text); } catch { return { text }; }
22
+ }
23
+
24
+ async function connect() {
25
+ const transport = new StdioClientTransport({
26
+ command: process.execPath,
27
+ args: [join(runtimeRoot, "dist", "index.js")],
28
+ cwd: runtimeRoot,
29
+ env: {
30
+ ...process.env,
31
+ PATCHWARDEN_CONFIG: configPath,
32
+ PATCHWARDEN_TOOL_PROFILE: profile,
33
+ },
34
+ stderr: "pipe",
35
+ });
36
+ const client = new Client({ name: "patchwarden-demo-e2e", version: "1.0.0" }, { capabilities: {} });
37
+ await client.connect(transport);
38
+ return client;
39
+ }
40
+
41
+ async function call(client, name, args = {}) {
42
+ return decode(await client.callTool({ name, arguments: args }));
43
+ }
44
+
45
+ async function waitForTask(client, taskId) {
46
+ for (;;) {
47
+ const result = await call(client, "wait_for_task", { task_id: taskId, wait_seconds: 30 });
48
+ if (!result?.continuation_required) return result;
49
+ }
50
+ }
51
+
52
+ function saveEvidence(value) {
53
+ mkdirSync(resolve(evidencePath, ".."), { recursive: true });
54
+ writeFileSync(evidencePath, JSON.stringify(value, null, 2), "utf8");
55
+ }
56
+
57
+ function loadEvidence() {
58
+ return JSON.parse(readFileSync(evidencePath, "utf8"));
59
+ }
60
+
61
+ async function runCorePrepare(client) {
62
+ const health = await call(client, "health_check", { detail: "self_diagnostic" });
63
+ const agents = await call(client, "list_agents");
64
+ const ordinary = await call(client, "create_task", {
65
+ template: "inspect_only",
66
+ goal: "Read the Demo fixture README and package.json, run the configured tests, and return a short verification summary. Do not modify files.",
67
+ repo_path: "patchwarden-demo-fixture",
68
+ agent: "codex",
69
+ verify_commands: ["npm test", "npm run lint"],
70
+ timeout_seconds: 180,
71
+ });
72
+ const ordinarySummary = await waitForTask(client, ordinary.task_id);
73
+ const ordinaryAudit = await call(client, "audit_task", { task_id: ordinary.task_id });
74
+ const assessmentArgs = {
75
+ execution_mode: "assess_only",
76
+ template: "inspect_only",
77
+ goal: "Read the Demo fixture README and package.json, run npm test and npm run lint, and return a short verification summary. Do not modify files.",
78
+ repo_path: "patchwarden-demo-fixture",
79
+ agent: "codex",
80
+ verify_commands: ["npm test", "npm run lint"],
81
+ timeout_seconds: 180,
82
+ };
83
+ const assessmentBeforeRestart = await call(client, "create_task", assessmentArgs);
84
+ const assessmentPolicyChange = await call(client, "create_task", assessmentArgs);
85
+ saveEvidence({
86
+ created_at: new Date().toISOString(),
87
+ profile,
88
+ runtime_root: runtimeRoot,
89
+ config_path: configPath,
90
+ fixture,
91
+ health,
92
+ agents,
93
+ ordinary_task_id: ordinary.task_id,
94
+ ordinary_summary: ordinarySummary,
95
+ ordinary_audit: ordinaryAudit,
96
+ assessment_before_restart: assessmentBeforeRestart,
97
+ assessment_policy_change: assessmentPolicyChange,
98
+ });
99
+ console.log(JSON.stringify({ ordinary_task_id: ordinary.task_id, assessment_before_restart: assessmentBeforeRestart, assessment_policy_change: assessmentPolicyChange }, null, 2));
100
+ }
101
+
102
+ async function runCoreExecute(client) {
103
+ const evidence = loadEvidence();
104
+ const first = evidence.assessment_before_restart;
105
+ const second = evidence.assessment_policy_change;
106
+ const execute = await call(client, "create_task", { execution_mode: "execute", assessment_id: first.assessment_id });
107
+ const summary = await waitForTask(client, execute.task_id);
108
+ const audit = await call(client, "audit_task", { task_id: execute.task_id });
109
+ const policyDir = join(fixture, ".patchwarden");
110
+ mkdirSync(policyDir, { recursive: true });
111
+ writeFileSync(join(policyDir, "project-policy.json"), JSON.stringify({ allowed_commands: ["node --check main.js"] }, null, 2), "utf8");
112
+ let stale = null;
113
+ try {
114
+ stale = await call(client, "create_task", { execution_mode: "execute", assessment_id: second.assessment_id });
115
+ } catch (error) {
116
+ stale = { error: error instanceof Error ? error.message : String(error) };
117
+ }
118
+ saveEvidence({ ...evidence, execute_task_id: execute.task_id, execute_summary: summary, execute_audit: audit, stale_after_policy_change: stale });
119
+ console.log(JSON.stringify({ execute_task_id: execute.task_id, execute_summary: summary, execute_audit: audit, stale_after_policy_change: stale }, null, 2));
120
+ }
121
+
122
+ async function runDirect(client) {
123
+ const health = await call(client, "health_check", { detail: "self_diagnostic" });
124
+ const session = await call(client, "create_direct_session", { repo_path: "patchwarden-demo-fixture", title: "PatchWarden Demo Direct verification" });
125
+ const file = await call(client, "read_workspace_file", { session_id: session.session_id, path: "main.js" });
126
+ const verification = await call(client, "run_direct_verification_bundle", {
127
+ session_id: session.session_id,
128
+ commands: ["npm test", "npm run lint", "node --check main.js"],
129
+ timeout_seconds: 120,
130
+ });
131
+ const finalized = await call(client, "finalize_direct_session", { session_id: session.session_id });
132
+ const audit = await call(client, "audit_session", { session_id: session.session_id });
133
+ saveEvidence({ direct_health: health, direct_session_id: session.session_id, direct_file: { sha256: file.sha256, bytes: file.bytes }, direct_verification: verification, direct_finalized: finalized, direct_audit: audit });
134
+ console.log(JSON.stringify({ direct_session_id: session.session_id, direct_verification: verification, direct_audit: audit }, null, 2));
135
+ }
136
+
137
+ async function runStatus(client) {
138
+ const health = await call(client, "health_check", { detail: "self_diagnostic" });
139
+ const agents = await call(client, "list_agents");
140
+ const tasks = await call(client, "list_tasks", { active_only: true, limit: 50 });
141
+ console.log(JSON.stringify({ health, agents, active_tasks: tasks }, null, 2));
142
+ }
143
+
144
+ const client = await connect();
145
+ try {
146
+ if (mode === "core-prepare") await runCorePrepare(client);
147
+ else if (mode === "core-execute") await runCoreExecute(client);
148
+ else if (mode === "direct") await runDirect(client);
149
+ else if (mode === "status") await runStatus(client);
150
+ else throw new Error(`Unsupported demo mode: ${mode}`);
151
+ } finally {
152
+ await client.close();
153
+ }
@@ -183,7 +183,7 @@ try {
183
183
  const manifestOutput = run("isolated packaged runtime manifest", process.execPath, ["scripts/checks/mcp-manifest-check.js", "--profile", "chatgpt_core"], isolatedRoot);
184
184
  report.runtime_manifest = JSON.parse(manifestOutput);
185
185
  if (!report.runtime_manifest.ok || report.runtime_manifest.server_version !== rootPackage.version || report.runtime_manifest.tool_count !== 26) {
186
- throw new Error("Packaged runtime manifest did not match the 1.6.1 26-tool contract.");
186
+ throw new Error(`Packaged runtime manifest did not match the ${rootPackage.version} 26-tool contract.`);
187
187
  }
188
188
 
189
189
  if (!skipUiSmoke) {