patchwarden 0.6.1 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/PatchWarden-Control-Tray.cmd +11 -0
  2. package/PatchWarden-Control.cmd +6 -0
  3. package/PatchWarden-Desktop.cmd +5 -0
  4. package/PatchWarden.cmd +1 -1
  5. package/README.en.md +106 -18
  6. package/README.md +30 -19
  7. package/Restart-PatchWarden-Control.cmd +6 -0
  8. package/Stop-PatchWarden.cmd +11 -0
  9. package/dist/controlCenter.d.ts +14 -0
  10. package/dist/controlCenter.js +2002 -0
  11. package/dist/doctor.js +3 -3
  12. package/dist/version.d.ts +1 -1
  13. package/dist/version.js +1 -1
  14. package/docs/control-center/README.md +33 -0
  15. package/docs/control-center/control-center-daily-driver.md +211 -0
  16. package/docs/control-center/control-center-mvp.md +205 -0
  17. package/docs/control-center/control-center-phase2.md +159 -0
  18. package/docs/demo.md +3 -0
  19. package/docs/release-v0.6.4.md +45 -0
  20. package/examples/openai-tunnel/README.md +5 -5
  21. package/examples/openai-tunnel/tunnel-client.example.yaml +3 -3
  22. package/package.json +23 -15
  23. package/scripts/README.md +47 -0
  24. package/scripts/{brand-check.js → checks/brand-check.js} +2 -2
  25. package/scripts/checks/control-center-smoke.js +1098 -0
  26. package/scripts/{control-smoke.js → checks/control-smoke.js} +17 -3
  27. package/scripts/{doctor-smoke.js → checks/doctor-smoke.js} +1 -1
  28. package/scripts/{http-mcp-smoke.js → checks/http-mcp-smoke.js} +2 -2
  29. package/scripts/{lifecycle-smoke.js → checks/lifecycle-smoke.js} +12 -12
  30. package/scripts/{mcp-manifest-check.js → checks/mcp-manifest-check.js} +2 -2
  31. package/scripts/{mcp-smoke.js → checks/mcp-smoke.js} +4 -2
  32. package/scripts/{package-manifest-check.js → checks/package-manifest-check.js} +2 -1
  33. package/scripts/{tunnel-supervisor-smoke.js → checks/tunnel-supervisor-smoke.js} +2 -2
  34. package/scripts/{unit-tests.js → checks/unit-tests.js} +1 -1
  35. package/scripts/{watcher-supervisor-smoke.js → checks/watcher-supervisor-smoke.js} +11 -5
  36. package/scripts/control/control-center-tray.ps1 +281 -0
  37. package/scripts/{get-patchwarden-health.ps1 → control/get-patchwarden-health.ps1} +3 -3
  38. package/scripts/{manage-patchwarden.ps1 → control/manage-patchwarden.ps1} +30 -4
  39. package/scripts/control/restart-control-center.ps1 +173 -0
  40. package/scripts/{restart-patchwarden.ps1 → control/restart-patchwarden.ps1} +1 -1
  41. package/scripts/control/start-control-center.ps1 +263 -0
  42. package/scripts/{start-patchwarden-tunnel.ps1 → control/start-patchwarden-tunnel.ps1} +48 -6
  43. package/scripts/control/stop-patchwarden.ps1 +114 -0
  44. package/scripts/launchers/Check-PatchWarden-Health.cmd +1 -1
  45. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +1 -1
  46. package/scripts/launchers/Restart-PatchWarden.cmd +1 -1
  47. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +1 -1
  48. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +1 -1
  49. package/scripts/{patchwarden-mcp-direct.cmd → mcp/patchwarden-mcp-direct.cmd} +1 -1
  50. package/scripts/{patchwarden-mcp-stdio.cmd → mcp/patchwarden-mcp-stdio.cmd} +1 -1
  51. package/scripts/{pack-clean.js → release/pack-clean.js} +9 -1
  52. package/src/controlCenter.ts +2152 -0
  53. package/src/doctor.ts +4 -4
  54. package/src/version.ts +1 -1
  55. package/ui/colors_and_type.css +141 -0
  56. package/ui/pages/audit.html +743 -0
  57. package/ui/pages/dashboard.html +1154 -0
  58. package/ui/pages/direct-sessions.html +652 -0
  59. package/ui/pages/logs.html +502 -0
  60. package/ui/pages/task-detail.html +1229 -0
  61. package/ui/pages/tasks.html +702 -0
  62. package/ui/pages/workspace.html +947 -0
  63. package/ui/partials/project-shell.html +362 -0
  64. package/ui/vendor/lucide.js +12 -0
  65. package/ui/vendor/tailwindcss-browser.js +947 -0
@@ -11,8 +11,8 @@ if (process.platform !== "win32") {
11
11
  }
12
12
 
13
13
  const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
14
- const root = resolve(scriptDir, "..");
15
- const manager = join(scriptDir, "manage-patchwarden.ps1");
14
+ const root = resolve(scriptDir, "..", "..");
15
+ const manager = join(root, "scripts", "control", "manage-patchwarden.ps1");
16
16
  const temp = mkdtempSync(join(tmpdir(), "patchwarden-control-smoke-"));
17
17
  const mockConfig = join(temp, "patchwarden.config.json");
18
18
  writeFileSync(mockConfig, JSON.stringify({
@@ -139,7 +139,13 @@ try {
139
139
 
140
140
  const expectedFiles = [
141
141
  "PatchWarden.cmd",
142
- "scripts/manage-patchwarden.ps1",
142
+ "PatchWarden-Control.cmd",
143
+ "PatchWarden-Control-Tray.cmd",
144
+ "PatchWarden-Desktop.cmd",
145
+ "Restart-PatchWarden-Control.cmd",
146
+ "Stop-PatchWarden.cmd",
147
+ "scripts/control/manage-patchwarden.ps1",
148
+ "scripts/control/stop-patchwarden.ps1",
143
149
  "scripts/launchers/Start-PatchWarden-Tunnel.cmd",
144
150
  "scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd",
145
151
  ];
@@ -152,6 +158,14 @@ try {
152
158
  if (!rootEntry.includes("manage-patchwarden.ps1")) {
153
159
  throw new Error("PatchWarden.cmd does not invoke the consolidated manager");
154
160
  }
161
+ const stopEntry = readFileSync(join(root, "Stop-PatchWarden.cmd"), "utf8");
162
+ if (!stopEntry.includes("scripts\\control\\stop-patchwarden.ps1")) {
163
+ throw new Error("Stop-PatchWarden.cmd does not invoke the one-click shutdown script");
164
+ }
165
+ const desktopEntry = readFileSync(join(root, "PatchWarden-Desktop.cmd"), "utf8");
166
+ if (!desktopEntry.includes("control-center-tray.ps1") || !desktopEntry.includes("WindowStyle Hidden")) {
167
+ throw new Error("PatchWarden-Desktop.cmd must launch the tray hidden as the daily desktop entry");
168
+ }
155
169
  console.log("ok - control handles orphan cleanup, scoped kill, port conflicts, health fallback, and Core/Direct lifecycle actions");
156
170
  } finally {
157
171
  if (fakeTunnel?.exitCode === null) fakeTunnel.kill();
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
- const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
8
+ const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
9
9
  const tempRoot = mkdtempSync(join(tmpdir(), "patchwarden-doctor-"));
10
10
  const workspaceRoot = join(tempRoot, "workspace");
11
11
  const configPath = join(tempRoot, "patchwarden.config.json");
@@ -11,10 +11,10 @@ import { tmpdir } from "node:os";
11
11
  import { dirname, join, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { setTimeout as sleep } from "node:timers/promises";
14
- import { CHATGPT_CORE_TOOL_NAMES } from "../dist/tools/toolCatalog.js";
14
+ import { CHATGPT_CORE_TOOL_NAMES } from "../../dist/tools/toolCatalog.js";
15
15
 
16
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
- const root = resolve(__dirname, "..");
17
+ const root = resolve(__dirname, "..", "..");
18
18
  const serverPath = resolve(root, "dist", "httpServer.js");
19
19
 
20
20
  const host = "127.0.0.1";
@@ -148,18 +148,18 @@ try {
148
148
  );
149
149
  process.env.PATCHWARDEN_CONFIG = configPath;
150
150
 
151
- const { savePlan } = await import("../dist/tools/savePlan.js");
152
- const { createTask } = await import("../dist/tools/createTask.js");
153
- const { getTaskStatus } = await import("../dist/tools/getTaskStatus.js");
154
- const { cancelTask } = await import("../dist/tools/cancelTask.js");
155
- const { killTask } = await import("../dist/tools/killTask.js");
156
- const { getDiff } = await import("../dist/tools/taskOutputs.js");
157
- const { listAgents } = await import("../dist/tools/listAgents.js");
158
- const { getTaskSummary } = await import("../dist/tools/getTaskSummary.js");
159
- const { auditTask } = await import("../dist/tools/auditTask.js");
160
- const { waitForTask } = await import("../dist/tools/waitForTask.js");
161
- const { runTask } = await import("../dist/runner/runTask.js");
162
- const { reloadConfig } = await import("../dist/config.js");
151
+ const { savePlan } = await import("../../dist/tools/savePlan.js");
152
+ const { createTask } = await import("../../dist/tools/createTask.js");
153
+ const { getTaskStatus } = await import("../../dist/tools/getTaskStatus.js");
154
+ const { cancelTask } = await import("../../dist/tools/cancelTask.js");
155
+ const { killTask } = await import("../../dist/tools/killTask.js");
156
+ const { getDiff } = await import("../../dist/tools/taskOutputs.js");
157
+ const { listAgents } = await import("../../dist/tools/listAgents.js");
158
+ const { getTaskSummary } = await import("../../dist/tools/getTaskSummary.js");
159
+ const { auditTask } = await import("../../dist/tools/auditTask.js");
160
+ const { waitForTask } = await import("../../dist/tools/waitForTask.js");
161
+ const { runTask } = await import("../../dist/runner/runTask.js");
162
+ const { reloadConfig } = await import("../../dist/config.js");
163
163
 
164
164
  await test("list_agents reports configured executables", async () => {
165
165
  const result = listAgents();
@@ -4,9 +4,9 @@ import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
6
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
- import { CHATGPT_CORE_TOOL_NAMES, CHATGPT_DIRECT_TOOL_NAMES } from "../dist/tools/toolCatalog.js";
7
+ import { CHATGPT_CORE_TOOL_NAMES, CHATGPT_DIRECT_TOOL_NAMES } from "../../dist/tools/toolCatalog.js";
8
8
 
9
- const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
9
+ const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
10
10
  const jsonOnly = process.argv.includes("--json");
11
11
  const profileIndex = process.argv.indexOf("--profile");
12
12
  const profile = profileIndex >= 0 ? process.argv[profileIndex + 1] : "chatgpt_core";
@@ -14,7 +14,9 @@ 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 root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
18
+ const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
19
+ const expectedServerVersion = packageJson.version;
18
20
  const tempRoot = mkdtempSync(join(tmpdir(), "patchwarden-mcp-"));
19
21
  const workspaceRoot = join(tempRoot, "workspace");
20
22
  const configPath = join(tempRoot, "patchwarden.config.json");
@@ -212,7 +214,7 @@ try {
212
214
  test_command: "npm test",
213
215
  });
214
216
  if (
215
- task.server_version !== "0.6.1" ||
217
+ task.server_version !== expectedServerVersion ||
216
218
  !/^[a-f0-9]{64}$/.test(task.tool_manifest_sha256 || "") ||
217
219
  task.next_tool_call?.name !== "wait_for_task" ||
218
220
  task.next_tool_call?.arguments?.timeout_seconds !== 25
@@ -45,7 +45,8 @@ if (leaked.length > 0) {
45
45
 
46
46
  const required = [
47
47
  "PatchWarden.cmd",
48
- "scripts/manage-patchwarden.ps1",
48
+ "PatchWarden-Desktop.cmd",
49
+ "scripts/control/manage-patchwarden.ps1",
49
50
  "scripts/launchers/Start-PatchWarden-Tunnel.cmd",
50
51
  "scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd",
51
52
  ];
@@ -10,7 +10,7 @@ if (process.platform !== "win32") {
10
10
  process.exit(0);
11
11
  }
12
12
 
13
- const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
13
+ const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
14
14
  const temp = mkdtempSync(join(tmpdir(), "patchwarden-tunnel-smoke-"));
15
15
  const mockJs = join(temp, "mock-tunnel-client.js");
16
16
  const mockCmd = join(temp, "mock-tunnel-client.cmd");
@@ -57,7 +57,7 @@ if(command==='run'){
57
57
  MOCK_TUNNEL_STATE: stateFile,
58
58
  };
59
59
  const result = spawnSync("powershell.exe", [
60
- "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(root, "scripts", "start-patchwarden-tunnel.ps1"),
60
+ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(root, "scripts", "control", "start-patchwarden-tunnel.ps1"),
61
61
  "-TunnelId", "tunnel_smoke_fixture",
62
62
  "-TunnelClientExe", mockCmd,
63
63
  "-ConfigPath", mockConfig,
@@ -5,7 +5,7 @@ import { resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
8
- const root = resolve(scriptDir, "..");
8
+ const root = resolve(scriptDir, "..", "..");
9
9
  const unitDir = resolve(root, "dist", "test", "unit");
10
10
 
11
11
  if (!existsSync(unitDir)) {
@@ -19,7 +19,7 @@ if (process.platform !== "win32") {
19
19
  process.exit(0);
20
20
  }
21
21
 
22
- const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
22
+ const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
23
23
  const temp = mkdtempSync(join(tmpdir(), "patchwarden-watcher-supervisor-"));
24
24
  const children = [];
25
25
 
@@ -87,13 +87,19 @@ async function runExternalScenario() {
87
87
  function createFixture(name) {
88
88
  const project = join(temp, name, "patchwarden-fixture");
89
89
  const scripts = join(project, "scripts");
90
+ const controlScripts = join(scripts, "control");
91
+ const mcpScripts = join(scripts, "mcp");
92
+ const checkScripts = join(scripts, "checks");
90
93
  const runner = join(project, "dist", "runner");
91
94
  const workspace = join(project, "workspace");
92
95
  mkdirSync(scripts, { recursive: true });
96
+ mkdirSync(controlScripts, { recursive: true });
97
+ mkdirSync(mcpScripts, { recursive: true });
98
+ mkdirSync(checkScripts, { recursive: true });
93
99
  mkdirSync(runner, { recursive: true });
94
100
  mkdirSync(workspace, { recursive: true });
95
- cpSync(join(root, "scripts", "start-patchwarden-tunnel.ps1"), join(scripts, "start-patchwarden-tunnel.ps1"));
96
- writeFileSync(join(scripts, "patchwarden-mcp-stdio.cmd"), "@echo off\r\nexit /b 0\r\n", "utf-8");
101
+ cpSync(join(root, "scripts", "control", "start-patchwarden-tunnel.ps1"), join(controlScripts, "start-patchwarden-tunnel.ps1"));
102
+ writeFileSync(join(mcpScripts, "patchwarden-mcp-stdio.cmd"), "@echo off\r\nexit /b 0\r\n", "utf-8");
97
103
  const manifestFixture = JSON.stringify({
98
104
  ok: true,
99
105
  server_version: "0.6.0",
@@ -103,7 +109,7 @@ function createFixture(name) {
103
109
  tool_names: [],
104
110
  tool_manifest_sha256: "a".repeat(64),
105
111
  });
106
- writeFileSync(join(scripts, "mcp-manifest-check.js"), `process.stdout.write(${JSON.stringify(manifestFixture)});\n`, "utf-8");
112
+ writeFileSync(join(checkScripts, "mcp-manifest-check.js"), `process.stdout.write(${JSON.stringify(manifestFixture)});\n`, "utf-8");
107
113
  writeFileSync(join(project, "dist", "index.js"), "", "utf-8");
108
114
  const watcherAttemptPath = join(project, "watcher-attempt.txt");
109
115
  const watcherPath = join(runner, "watch.js");
@@ -140,7 +146,7 @@ function createFixture(name) {
140
146
 
141
147
  function runLauncher(fixture, mode, maxRestarts, lifetimeMs) {
142
148
  return spawnSync("powershell.exe", [
143
- "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(fixture.project, "scripts", "start-patchwarden-tunnel.ps1"),
149
+ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(fixture.project, "scripts", "control", "start-patchwarden-tunnel.ps1"),
144
150
  "-TunnelId", "tunnel_watcher_fixture",
145
151
  "-TunnelClientExe", fixture.mockCmd,
146
152
  "-ProxyUrl", "http://127.0.0.1:1",
@@ -0,0 +1,281 @@
1
+ #Requires -Version 5.1
2
+ <#
3
+ .SYNOPSIS
4
+ Lightweight PatchWarden Control Center system tray entry.
5
+ .DESCRIPTION
6
+ Builds a Windows NotifyIcon (no Electron, no extra dependencies) whose menu
7
+ drives the Control Center via its HTTP API with the control token. If the
8
+ Control Center is not running when the tray starts, it is launched in the
9
+ background first.
10
+
11
+ Menu:
12
+ - Open Dashboard (opens the dashboard in the default browser)
13
+ - Status (shows a compact Core/Direct/Watcher summary)
14
+ - Start All (POST /api/start-all)
15
+ - Stop All (POST /api/stop-all)
16
+ - Restart All (POST /api/restart-all)
17
+ - Open Logs (POST /api/open-logs-folder)
18
+ - Quit Tray (disposes the tray and exits)
19
+ #>
20
+ param(
21
+ [int]$Port = 8090,
22
+ [switch]$NoStartupBalloon
23
+ )
24
+
25
+ $ErrorActionPreference = "Stop"
26
+ $ProjectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
27
+ $BaseUrl = "http://127.0.0.1:$Port/"
28
+ $RuntimeDirectory = Join-Path $env:LOCALAPPDATA "patchwarden\control-center"
29
+ $StatusFile = Join-Path $RuntimeDirectory "control-center-status.json"
30
+ $MutexName = "Local\PatchWarden-Control-Tray-" + ([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($ProjectRoot)) -replace "[^A-Za-z0-9]", "").Substring(0, 16)
31
+
32
+ # ── Load WinForms + Drawing ──────────────────────────────────────
33
+ Add-Type -AssemblyName System.Windows.Forms
34
+ Add-Type -AssemblyName System.Drawing
35
+
36
+ $createdMutex = $false
37
+ $trayMutex = New-Object System.Threading.Mutex($true, $MutexName, [ref]$createdMutex)
38
+ if (-not $createdMutex) {
39
+ $trayMutex.Dispose()
40
+ exit 0
41
+ }
42
+
43
+ # ── Helpers ──────────────────────────────────────────────────────
44
+
45
+ function Invoke-ControlApi {
46
+ param([Parameter(Mandatory = $true)][string]$Path, [string]$Method = "POST")
47
+ $url = $BaseUrl.TrimEnd("/") + $Path
48
+ # Fetch the control token (in-memory, per server run).
49
+ $token = $null
50
+ try {
51
+ $tokenResp = Invoke-WebRequest -Uri ($BaseUrl.TrimEnd("/") + "/control-token.json") -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop
52
+ $token = ($tokenResp.Content | ConvertFrom-Json).token
53
+ } catch {
54
+ [System.Windows.Forms.MessageBox]::Show(
55
+ "Could not reach the Control Center at $BaseUrl.`n`nError: $($_.Exception.Message)",
56
+ "PatchWarden Tray",
57
+ [System.Windows.Forms.MessageBoxButtons]::OK,
58
+ [System.Windows.Forms.MessageBoxIcon]::Warning
59
+ ) | Out-Null
60
+ return $null
61
+ }
62
+ $headers = @{ "X-PatchWarden-Control-Token" = $token }
63
+ try {
64
+ $resp = Invoke-WebRequest -Uri $url -Method $Method -Headers $headers -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
65
+ return $resp.Content | ConvertFrom-Json
66
+ } catch {
67
+ [System.Windows.Forms.MessageBox]::Show(
68
+ "API call failed: $Path`n`nError: $($_.Exception.Message)",
69
+ "PatchWarden Tray",
70
+ [System.Windows.Forms.MessageBoxButtons]::OK,
71
+ [System.Windows.Forms.MessageBoxIcon]::Warning
72
+ ) | Out-Null
73
+ return $null
74
+ }
75
+ }
76
+
77
+ function New-PatchWardenTrayIcon {
78
+ $bitmap = New-Object System.Drawing.Bitmap 32, 32
79
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
80
+ $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
81
+ $graphics.Clear([System.Drawing.Color]::Transparent)
82
+
83
+ $background = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(255, 10, 14, 20))
84
+ $accent = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(255, 45, 212, 168))
85
+ $darkPen = New-Object System.Drawing.Pen -ArgumentList ([System.Drawing.Color]::FromArgb(255, 5, 9, 14)), 1.5
86
+
87
+ $graphics.FillEllipse($background, 1, 1, 30, 30)
88
+ $points = [System.Drawing.Point[]]@(
89
+ [System.Drawing.Point]::new(16, 5),
90
+ [System.Drawing.Point]::new(25, 9),
91
+ [System.Drawing.Point]::new(24, 17),
92
+ [System.Drawing.Point]::new(16, 27),
93
+ [System.Drawing.Point]::new(8, 17),
94
+ [System.Drawing.Point]::new(7, 9)
95
+ )
96
+ $graphics.FillPolygon($accent, $points)
97
+ $graphics.DrawPolygon($darkPen, $points)
98
+ $graphics.DrawLine($darkPen, 16, 8, 16, 23)
99
+ $graphics.DrawLine($darkPen, 11, 15, 21, 15)
100
+
101
+ $handle = $bitmap.GetHicon()
102
+ try {
103
+ return [System.Drawing.Icon]::FromHandle($handle)
104
+ } finally {
105
+ $graphics.Dispose()
106
+ $background.Dispose()
107
+ $accent.Dispose()
108
+ $darkPen.Dispose()
109
+ }
110
+ }
111
+
112
+ function Show-TrayBalloon {
113
+ param([string]$Title, [string]$Text, [System.Windows.Forms.ToolTipIcon]$Icon = [System.Windows.Forms.ToolTipIcon]::Info)
114
+ if ($null -eq $script:notifyIcon) { return }
115
+ $script:notifyIcon.BalloonTipTitle = $Title
116
+ $script:notifyIcon.BalloonTipText = $Text
117
+ $script:notifyIcon.BalloonTipIcon = $Icon
118
+ $script:notifyIcon.ShowBalloonTip(2200)
119
+ }
120
+
121
+ function Invoke-TrayControlAction {
122
+ param([string]$Path, [string]$SuccessText)
123
+ $result = Invoke-ControlApi -Path $Path
124
+ if ($null -eq $result) { return }
125
+ if ($result.ok -eq $false) {
126
+ Show-TrayBalloon -Title "PatchWarden needs attention" -Text "Open Dashboard for details." -Icon ([System.Windows.Forms.ToolTipIcon]::Warning)
127
+ return
128
+ }
129
+ Show-TrayBalloon -Title "PatchWarden" -Text $SuccessText
130
+ }
131
+
132
+ function Test-ControlCenterRunning {
133
+ # Probe the status endpoint to confirm the server is live and is ours.
134
+ try {
135
+ $resp = Invoke-WebRequest -Uri ($BaseUrl.TrimEnd("/") + "/api/control-center-status") -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop
136
+ if ($resp.StatusCode -eq 200) {
137
+ $body = $resp.Content | ConvertFrom-Json
138
+ return [bool]$body.running
139
+ }
140
+ } catch {}
141
+ return $false
142
+ }
143
+
144
+ function Ensure-ControlCenterRunning {
145
+ if (Test-ControlCenterRunning) { return $true }
146
+ Write-Host "[tray] Preparing PatchWarden Control Center..."
147
+ $startScript = Join-Path $PSScriptRoot "start-control-center.ps1"
148
+ if (-not (Test-Path -LiteralPath $startScript)) {
149
+ [System.Windows.Forms.MessageBox]::Show(
150
+ "start-control-center.ps1 not found at:`n$startScript",
151
+ "PatchWarden Tray",
152
+ [System.Windows.Forms.MessageBoxButtons]::OK,
153
+ [System.Windows.Forms.MessageBoxIcon]::Error
154
+ ) | Out-Null
155
+ return $false
156
+ }
157
+ try {
158
+ Start-Process -FilePath "powershell.exe" `
159
+ -ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $startScript, "-NoBrowser") `
160
+ -WindowStyle Hidden -Wait -ErrorAction Stop
161
+ } catch {
162
+ [System.Windows.Forms.MessageBox]::Show(
163
+ "Failed to start the Control Center:`n$($_.Exception.Message)",
164
+ "PatchWarden Tray",
165
+ [System.Windows.Forms.MessageBoxButtons]::OK,
166
+ [System.Windows.Forms.MessageBoxIcon]::Error
167
+ ) | Out-Null
168
+ return $false
169
+ }
170
+ # Wait up to 20s for readiness.
171
+ for ($i = 0; $i -lt 20; $i++) {
172
+ Start-Sleep -Seconds 1
173
+ if (Test-ControlCenterRunning) { return $true }
174
+ }
175
+ return $false
176
+ }
177
+
178
+ function Show-CompactStatus {
179
+ $data = Invoke-ControlApi -Path "/api/status" -Method "GET"
180
+ if ($null -eq $data) { return }
181
+
182
+ $core = if ($data.core.available) { "available" } else { "stopped" }
183
+ $direct = if ($data.direct.available) { "available" } else { "stopped" }
184
+ $watcher = if ($data.watcher.status) { [string]$data.watcher.status } else { "unknown" }
185
+ $workspace = if ($data.workspace_root) { [string]$data.workspace_root } else { "not configured" }
186
+
187
+ $message = @(
188
+ "Core: $core"
189
+ "Direct: $direct"
190
+ "Watcher: $watcher"
191
+ ""
192
+ "Workspace:"
193
+ $workspace
194
+ ""
195
+ "Open Dashboard for tasks, audit logs, Direct sessions, and setup details."
196
+ ) -join "`n"
197
+
198
+ [System.Windows.Forms.MessageBox]::Show(
199
+ $message,
200
+ "PatchWarden Status",
201
+ [System.Windows.Forms.MessageBoxButtons]::OK,
202
+ [System.Windows.Forms.MessageBoxIcon]::Information
203
+ ) | Out-Null
204
+ }
205
+
206
+ # ── Ensure the control center is up before showing the tray ──────
207
+ [void](Ensure-ControlCenterRunning)
208
+
209
+ # ── Build the tray icon ──────────────────────────────────────────
210
+ $script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon
211
+ $notifyIcon = $script:notifyIcon
212
+ try {
213
+ $notifyIcon.Icon = New-PatchWardenTrayIcon
214
+ } catch {
215
+ $notifyIcon.Icon = [System.Drawing.SystemIcons]::Shield
216
+ }
217
+ $notifyIcon.Text = "PatchWarden: local control"
218
+ $notifyIcon.Visible = $true
219
+
220
+ # Double-click opens the dashboard.
221
+ $notifyIcon.Add_DoubleClick({
222
+ Start-Process -FilePath $BaseUrl
223
+ })
224
+
225
+ # ── Context menu ─────────────────────────────────────────────────
226
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
227
+
228
+ [void]$menu.Items.Add("Open Dashboard", $null, {
229
+ Start-Process -FilePath $BaseUrl
230
+ })
231
+
232
+ [void]$menu.Items.Add("Status", $null, {
233
+ Show-CompactStatus
234
+ })
235
+
236
+ [void]$menu.Items.Add("-") # separator
237
+
238
+ [void]$menu.Items.Add("Start All", $null, {
239
+ Invoke-TrayControlAction -Path "/api/start-all" -SuccessText "Core and Direct are starting in the background."
240
+ })
241
+
242
+ [void]$menu.Items.Add("Stop All", $null, {
243
+ Invoke-TrayControlAction -Path "/api/stop-all" -SuccessText "Core and Direct were asked to stop. Tray and dashboard stay available."
244
+ })
245
+
246
+ [void]$menu.Items.Add("Restart All", $null, {
247
+ Invoke-TrayControlAction -Path "/api/restart-all" -SuccessText "Core and Direct are restarting in the background."
248
+ })
249
+
250
+ [void]$menu.Items.Add("-") # separator
251
+
252
+ [void]$menu.Items.Add("Open Logs", $null, {
253
+ Invoke-TrayControlAction -Path "/api/open-logs-folder" -SuccessText "Opening PatchWarden runtime logs."
254
+ })
255
+
256
+ [void]$menu.Items.Add("-") # separator
257
+
258
+ [void]$menu.Items.Add("Quit Tray", $null, {
259
+ $notifyIcon.Visible = $false
260
+ [System.Windows.Forms.Application]::Exit()
261
+ })
262
+
263
+ $notifyIcon.ContextMenuStrip = $menu
264
+
265
+ # Show a brief balloon on launch.
266
+ if (-not $NoStartupBalloon) {
267
+ Show-TrayBalloon -Title "PatchWarden is ready" -Text "Right-click for quick controls. Open Dashboard for the full console."
268
+ }
269
+
270
+ Write-Host "[tray] PatchWarden Control Center tray is active. Right-click the icon for actions."
271
+
272
+ # Run the application loop. Without this the script exits immediately and the
273
+ # tray disappears. The loop exits when Application::Exit() is called from the
274
+ # Exit menu item.
275
+ [System.Windows.Forms.Application]::Run()
276
+
277
+ # Cleanup on exit.
278
+ $notifyIcon.Visible = $false
279
+ $notifyIcon.Dispose()
280
+ if ($trayMutex) { $trayMutex.ReleaseMutex(); $trayMutex.Dispose() }
281
+ Write-Host "[tray] Exited."
@@ -1,6 +1,6 @@
1
1
  $ErrorActionPreference = "Stop"
2
2
 
3
- $ProjectRoot = Split-Path -Parent $PSScriptRoot
3
+ $ProjectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
4
4
  $ConfigPath = Join-Path $ProjectRoot "patchwarden.config.json"
5
5
  $RuntimeDirectory = Join-Path $env:LOCALAPPDATA "patchwarden\runtime"
6
6
  $HealthUrlFile = Join-Path $RuntimeDirectory "tunnel-health-url.txt"
@@ -15,7 +15,7 @@ $packageVersion = [string](Get-Content -LiteralPath (Join-Path $ProjectRoot "pac
15
15
 
16
16
  $configuredTunnelManifest = $null
17
17
  try {
18
- $manifestJson = node (Join-Path $ProjectRoot "scripts\mcp-manifest-check.js") 2>$null
18
+ $manifestJson = node (Join-Path $ProjectRoot "scripts\checks\mcp-manifest-check.js") 2>$null
19
19
  if ($LASTEXITCODE -eq 0 -and $manifestJson) {
20
20
  $configuredTunnelManifest = $manifestJson | ConvertFrom-Json
21
21
  }
@@ -70,7 +70,7 @@ if (Test-Path -LiteralPath $HealthUrlFile) {
70
70
 
71
71
  $tunnelProcesses = @($safeProcesses | Where-Object { $_.name -eq "tunnel-client.exe" })
72
72
  $mcpChildProcesses = @($safeProcesses | Where-Object {
73
- $_.command_line -match '(?i)patchwarden-mcp-stdio\.cmd|patchwarden\\dist\\index\.js|patchwarden/scripts/\.\./dist/index\.js'
73
+ $_.command_line -match '(?i)patchwarden-mcp-stdio\.cmd|patchwarden\\dist\\index\.js|patchwarden/scripts/mcp/../../dist/index.js'
74
74
  })
75
75
  $deploymentConsistent = [bool](
76
76
  $configuredTunnelManifest -and
@@ -1,4 +1,4 @@
1
- [CmdletBinding()]
1
+ [CmdletBinding()]
2
2
  param(
3
3
  [Parameter(Position = 0)]
4
4
  [ValidateSet("menu", "start", "stop", "restart", "status", "health", "reset-key", "kill")]
@@ -11,13 +11,14 @@ param(
11
11
  [switch]$SkipBuild,
12
12
  [switch]$WhatIf,
13
13
  [switch]$Json,
14
+ [switch]$Background,
14
15
  [int]$KillTimeoutSeconds = 10
15
16
  )
16
17
 
17
18
  $ErrorActionPreference = "Stop"
18
19
  $ProgressPreference = "SilentlyContinue"
19
- $ProjectRoot = Split-Path -Parent $PSScriptRoot
20
- $LauncherDirectory = Join-Path $PSScriptRoot "launchers"
20
+ $ProjectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
21
+ $LauncherDirectory = Join-Path $ProjectRoot "scripts\launchers"
21
22
  $LocalLauncherDirectory = Join-Path $ProjectRoot ".local\launchers"
22
23
  $PatchWardenRuntimeRoot = Join-Path $env:LOCALAPPDATA "patchwarden"
23
24
 
@@ -518,7 +519,32 @@ function Start-Mode {
518
519
  }
519
520
  Assert-NoUnsafePortConflicts -Definitions @($Definition)
520
521
 
521
- $launcher = if (Test-Path -LiteralPath $Definition.LocalLauncher) { $Definition.LocalLauncher } else { $Definition.GenericLauncher }
522
+ if ($Background) {
523
+ $launcherScript = Join-Path $PSScriptRoot "start-patchwarden-tunnel.ps1"
524
+ if (-not (Test-Path -LiteralPath $launcherScript)) { throw "Launcher script not found: $launcherScript" }
525
+ $healthListenAddr = ([Uri]$Definition.HealthBaseUrl).Authority
526
+ $arguments = @(
527
+ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $launcherScript,
528
+ "-ToolProfile", $Definition.ToolProfile,
529
+ "-Profile", $Definition.Profile,
530
+ "-HealthListenAddr", $healthListenAddr,
531
+ "-NoTunnelWebUi"
532
+ )
533
+ if (-not $Definition.HasWatcher) { $arguments += "-SkipWatcher" }
534
+ if ($WhatIf) {
535
+ Write-Host "[start:$($Definition.Key)] WHAT-IF: start hidden supervisor for $($Definition.Profile)" -ForegroundColor Magenta
536
+ return
537
+ }
538
+ $process = Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -WorkingDirectory $ProjectRoot -PassThru -WindowStyle Hidden
539
+ Write-Host "[start:$($Definition.Key)] Started hidden supervisor PID $($process.Id). Logs: $($Definition.RuntimeDirectory)" -ForegroundColor Green
540
+ return
541
+ }
542
+
543
+ # Prefer the project's built-in launcher (scripts\launchers\*.cmd) over
544
+ # .local\launchers\*.local.cmd. A .local launcher, if present, MUST call
545
+ # the project root scripts\launchers\*.cmd via relative path (..\..\scripts\launchers\*),
546
+ # NOT assume its own directory contains the target script.
547
+ $launcher = if (Test-Path -LiteralPath $Definition.GenericLauncher) { $Definition.GenericLauncher } else { $Definition.LocalLauncher }
522
548
  if (-not (Test-Path -LiteralPath $launcher)) { throw "Launcher not found: $launcher" }
523
549
  $launcherName = Split-Path -Leaf $launcher
524
550
  if ($WhatIf) {