patchwarden 0.4.0 → 0.6.1

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 (189) hide show
  1. package/PatchWarden.cmd +51 -0
  2. package/README.en.md +1022 -0
  3. package/README.md +897 -358
  4. package/dist/assessments/agentAssessor.d.ts +15 -0
  5. package/dist/assessments/agentAssessor.js +293 -0
  6. package/dist/assessments/assessmentStore.d.ts +133 -0
  7. package/dist/assessments/assessmentStore.js +238 -0
  8. package/dist/assessments/confirmCli.d.ts +9 -0
  9. package/dist/assessments/confirmCli.js +26 -0
  10. package/dist/config.d.ts +22 -1
  11. package/dist/config.js +156 -3
  12. package/dist/direct/directAudit.d.ts +23 -0
  13. package/dist/direct/directAudit.js +309 -0
  14. package/dist/direct/directGuards.d.ts +20 -0
  15. package/dist/direct/directGuards.js +137 -0
  16. package/dist/direct/directPatch.d.ts +17 -0
  17. package/dist/direct/directPatch.js +113 -0
  18. package/dist/direct/directSessionStore.d.ts +63 -0
  19. package/dist/direct/directSessionStore.js +192 -0
  20. package/dist/direct/directVerification.d.ts +12 -0
  21. package/dist/direct/directVerification.js +96 -0
  22. package/dist/doctor.js +73 -5
  23. package/dist/logging.d.ts +52 -0
  24. package/dist/logging.js +123 -0
  25. package/dist/runner/agentInvocation.d.ts +23 -0
  26. package/dist/runner/agentInvocation.js +106 -0
  27. package/dist/runner/changeCapture.d.ts +70 -0
  28. package/dist/runner/changeCapture.js +278 -6
  29. package/dist/runner/runTask.js +271 -65
  30. package/dist/runner/simpleProcess.d.ts +21 -0
  31. package/dist/runner/simpleProcess.js +206 -0
  32. package/dist/runner/watch.js +17 -2
  33. package/dist/security/commandGuard.d.ts +2 -1
  34. package/dist/security/commandGuard.js +25 -3
  35. package/dist/security/riskEngine.d.ts +27 -0
  36. package/dist/security/riskEngine.js +118 -0
  37. package/dist/security/runtimeGuard.d.ts +6 -0
  38. package/dist/security/runtimeGuard.js +28 -0
  39. package/dist/smoke-test.js +1353 -7
  40. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  41. package/dist/test/unit/android-doctor.test.js +118 -0
  42. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  43. package/dist/test/unit/chinese-path.test.js +91 -0
  44. package/dist/test/unit/command-guard.test.d.ts +1 -0
  45. package/dist/test/unit/command-guard.test.js +160 -0
  46. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  47. package/dist/test/unit/direct-guards.test.js +213 -0
  48. package/dist/test/unit/logging.test.d.ts +1 -0
  49. package/dist/test/unit/logging.test.js +275 -0
  50. package/dist/test/unit/path-guard.test.d.ts +1 -0
  51. package/dist/test/unit/path-guard.test.js +109 -0
  52. package/dist/test/unit/safe-status.test.d.ts +1 -0
  53. package/dist/test/unit/safe-status.test.js +165 -0
  54. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  55. package/dist/test/unit/sensitive-guard.test.js +104 -0
  56. package/dist/test/unit/sync-file.test.d.ts +1 -0
  57. package/dist/test/unit/sync-file.test.js +154 -0
  58. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  59. package/dist/test/unit/watcher-status.test.js +169 -0
  60. package/dist/tools/androidDoctor.d.ts +38 -0
  61. package/dist/tools/androidDoctor.js +391 -0
  62. package/dist/tools/applyPatch.d.ts +16 -0
  63. package/dist/tools/applyPatch.js +41 -0
  64. package/dist/tools/auditSession.d.ts +5 -0
  65. package/dist/tools/auditSession.js +12 -0
  66. package/dist/tools/auditTask.d.ts +7 -0
  67. package/dist/tools/auditTask.js +105 -7
  68. package/dist/tools/createDirectSession.d.ts +14 -0
  69. package/dist/tools/createDirectSession.js +54 -0
  70. package/dist/tools/createTask.d.ts +48 -1
  71. package/dist/tools/createTask.js +298 -47
  72. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  73. package/dist/tools/finalizeDirectSession.js +84 -0
  74. package/dist/tools/getTaskSummary.d.ts +50 -0
  75. package/dist/tools/getTaskSummary.js +79 -2
  76. package/dist/tools/healthCheck.d.ts +27 -12
  77. package/dist/tools/healthCheck.js +44 -6
  78. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  79. package/dist/tools/readWorkspaceFile.js +48 -2
  80. package/dist/tools/registry.js +270 -19
  81. package/dist/tools/runVerification.d.ts +16 -0
  82. package/dist/tools/runVerification.js +32 -0
  83. package/dist/tools/safeStatus.d.ts +19 -0
  84. package/dist/tools/safeStatus.js +72 -0
  85. package/dist/tools/savePlan.d.ts +1 -0
  86. package/dist/tools/savePlan.js +38 -7
  87. package/dist/tools/searchWorkspace.d.ts +19 -0
  88. package/dist/tools/searchWorkspace.js +205 -0
  89. package/dist/tools/syncFile.d.ts +18 -0
  90. package/dist/tools/syncFile.js +65 -0
  91. package/dist/tools/taskOutputs.d.ts +2 -2
  92. package/dist/tools/taskTemplates.js +2 -1
  93. package/dist/tools/toolCatalog.d.ts +4 -3
  94. package/dist/tools/toolCatalog.js +34 -5
  95. package/dist/tools/waitForTask.d.ts +2 -2
  96. package/dist/tools/waitForTask.js +1 -1
  97. package/dist/version.d.ts +2 -2
  98. package/dist/version.js +2 -2
  99. package/dist/watcherStatus.d.ts +1 -0
  100. package/dist/watcherStatus.js +96 -4
  101. package/docs/chatgpt-usage.md +101 -0
  102. package/docs/performance-notes.md +55 -0
  103. package/docs/release-checklist.md +14 -0
  104. package/docs/release-v0.6.0.md +71 -0
  105. package/docs/release-v0.6.1.md +75 -0
  106. package/examples/config.example.json +5 -0
  107. package/examples/openai-tunnel/README.md +11 -4
  108. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  109. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  110. package/package.json +13 -8
  111. package/scripts/brand-check.js +58 -12
  112. package/scripts/control-smoke.js +206 -0
  113. package/scripts/http-mcp-smoke.js +10 -2
  114. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  115. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  116. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  117. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  118. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  119. package/scripts/lifecycle-smoke.js +402 -8
  120. package/scripts/manage-patchwarden.ps1 +639 -0
  121. package/scripts/mcp-manifest-check.js +123 -58
  122. package/scripts/mcp-smoke.js +160 -2
  123. package/scripts/pack-clean.js +160 -5
  124. package/scripts/package-manifest-check.js +78 -0
  125. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  126. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  127. package/scripts/restart-patchwarden.ps1 +5 -4
  128. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  129. package/scripts/tunnel-supervisor-smoke.js +36 -4
  130. package/scripts/unit-tests.js +36 -0
  131. package/scripts/watcher-supervisor-smoke.js +10 -6
  132. package/src/assessments/agentAssessor.ts +324 -0
  133. package/src/assessments/assessmentStore.ts +426 -0
  134. package/src/assessments/confirmCli.ts +29 -0
  135. package/src/config.ts +178 -4
  136. package/src/direct/directAudit.ts +400 -0
  137. package/src/direct/directGuards.ts +279 -0
  138. package/src/direct/directPatch.ts +258 -0
  139. package/src/direct/directSessionStore.ts +345 -0
  140. package/src/direct/directVerification.ts +138 -0
  141. package/src/doctor.ts +103 -7
  142. package/src/logging.ts +152 -0
  143. package/src/runner/agentInvocation.ts +125 -0
  144. package/src/runner/changeCapture.ts +352 -6
  145. package/src/runner/runTask.ts +279 -63
  146. package/src/runner/simpleProcess.ts +223 -0
  147. package/src/runner/watch.ts +18 -2
  148. package/src/security/commandGuard.ts +46 -4
  149. package/src/security/riskEngine.ts +160 -0
  150. package/src/security/runtimeGuard.ts +41 -0
  151. package/src/smoke-test.ts +1291 -4
  152. package/src/test/unit/android-doctor.test.ts +158 -0
  153. package/src/test/unit/chinese-path.test.ts +106 -0
  154. package/src/test/unit/command-guard.test.ts +221 -0
  155. package/src/test/unit/direct-guards.test.ts +297 -0
  156. package/src/test/unit/logging.test.ts +325 -0
  157. package/src/test/unit/path-guard.test.ts +150 -0
  158. package/src/test/unit/safe-status.test.ts +187 -0
  159. package/src/test/unit/sensitive-guard.test.ts +124 -0
  160. package/src/test/unit/sync-file.test.ts +231 -0
  161. package/src/test/unit/watcher-status.test.ts +190 -0
  162. package/src/tools/androidDoctor.ts +424 -0
  163. package/src/tools/applyPatch.ts +86 -0
  164. package/src/tools/auditSession.ts +28 -0
  165. package/src/tools/auditTask.ts +111 -7
  166. package/src/tools/createDirectSession.ts +113 -0
  167. package/src/tools/createTask.ts +405 -55
  168. package/src/tools/finalizeDirectSession.ts +144 -0
  169. package/src/tools/getTaskSummary.ts +133 -2
  170. package/src/tools/healthCheck.ts +45 -6
  171. package/src/tools/readWorkspaceFile.ts +85 -2
  172. package/src/tools/registry.ts +305 -19
  173. package/src/tools/runVerification.ts +58 -0
  174. package/src/tools/safeStatus.ts +96 -0
  175. package/src/tools/savePlan.ts +57 -7
  176. package/src/tools/searchWorkspace.ts +275 -0
  177. package/src/tools/syncFile.ts +122 -0
  178. package/src/tools/taskTemplates.ts +2 -1
  179. package/src/tools/toolCatalog.ts +37 -6
  180. package/src/tools/waitForTask.ts +3 -3
  181. package/src/version.ts +2 -2
  182. package/src/watcherStatus.ts +101 -4
  183. package/tsconfig.json +18 -17
  184. package/Check-PatchWarden-Health.cmd +0 -6
  185. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  186. package/Restart-PatchWarden.cmd +0 -19
  187. package/Start-PatchWarden-Tunnel.cmd +0 -7
  188. package/docs/release-v0.3.0.md +0 -43
  189. package/docs/release-v0.4.0.md +0 -74
@@ -0,0 +1,639 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [Parameter(Position = 0)]
4
+ [ValidateSet("menu", "start", "stop", "restart", "status", "health", "reset-key", "kill")]
5
+ [string]$Action = "menu",
6
+
7
+ [Parameter(Position = 1)]
8
+ [ValidateSet("core", "direct", "all")]
9
+ [string]$Mode = "all",
10
+
11
+ [switch]$SkipBuild,
12
+ [switch]$WhatIf,
13
+ [switch]$Json,
14
+ [int]$KillTimeoutSeconds = 10
15
+ )
16
+
17
+ $ErrorActionPreference = "Stop"
18
+ $ProgressPreference = "SilentlyContinue"
19
+ $ProjectRoot = Split-Path -Parent $PSScriptRoot
20
+ $LauncherDirectory = Join-Path $PSScriptRoot "launchers"
21
+ $LocalLauncherDirectory = Join-Path $ProjectRoot ".local\launchers"
22
+ $PatchWardenRuntimeRoot = Join-Path $env:LOCALAPPDATA "patchwarden"
23
+
24
+ $ModeDefinitions = [ordered]@{
25
+ core = [pscustomobject]@{
26
+ Key = "core"
27
+ Label = "Core Agent"
28
+ Profile = "patchwarden"
29
+ ToolProfile = "chatgpt_core"
30
+ RuntimeDirectory = Join-Path $PatchWardenRuntimeRoot "runtime"
31
+ HealthBaseUrl = "http://127.0.0.1:8080"
32
+ LegacyPidFile = Join-Path $env:TEMP "patchwarden-core.pid"
33
+ LegacyHealthUrlFile = Join-Path $env:TEMP "patchwarden-core-health.url"
34
+ GenericLauncher = Join-Path $LauncherDirectory "Start-PatchWarden-Tunnel.cmd"
35
+ LocalLauncher = Join-Path $LocalLauncherDirectory "Start-PatchWarden-Tunnel.local.cmd"
36
+ HasWatcher = $true
37
+ }
38
+ direct = [pscustomobject]@{
39
+ Key = "direct"
40
+ Label = "Direct"
41
+ Profile = "patchwarden-direct"
42
+ ToolProfile = "chatgpt_direct"
43
+ RuntimeDirectory = Join-Path $PatchWardenRuntimeRoot "runtime-direct"
44
+ HealthBaseUrl = "http://127.0.0.1:8081"
45
+ LegacyPidFile = Join-Path $env:TEMP "patchwarden-direct.pid"
46
+ LegacyHealthUrlFile = Join-Path $env:TEMP "patchwarden-direct-health.url"
47
+ GenericLauncher = Join-Path $LauncherDirectory "Start-PatchWarden-Direct-Tunnel.cmd"
48
+ LocalLauncher = Join-Path $LocalLauncherDirectory "Start-PatchWarden-Direct-Tunnel.local.cmd"
49
+ HasWatcher = $false
50
+ }
51
+ }
52
+
53
+ function Get-SelectedModes {
54
+ param([string]$SelectedMode)
55
+ if ($SelectedMode -eq "all") {
56
+ return @($ModeDefinitions.core, $ModeDefinitions.direct)
57
+ }
58
+ return @($ModeDefinitions[$SelectedMode])
59
+ }
60
+
61
+ function Get-ProcessByIdSafe {
62
+ param([Nullable[int]]$ProcessId)
63
+ if (-not $ProcessId -or $ProcessId -le 0) { return $null }
64
+ try {
65
+ return Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction Stop
66
+ } catch {
67
+ return $null
68
+ }
69
+ }
70
+
71
+ function Read-JsonFileSafe {
72
+ param([string]$Path)
73
+ if (-not (Test-Path -LiteralPath $Path)) { return $null }
74
+ try {
75
+ return Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
76
+ } catch {
77
+ return $null
78
+ }
79
+ }
80
+
81
+ function Read-FileLineSafe {
82
+ param([string]$Path)
83
+ if (-not (Test-Path -LiteralPath $Path)) { return $null }
84
+ try {
85
+ return (Get-Content -LiteralPath $Path -Raw -Encoding UTF8).Trim()
86
+ } catch {
87
+ return $null
88
+ }
89
+ }
90
+
91
+ function Test-TunnelHealthEndpoint {
92
+ param([string]$HealthUrl)
93
+ if (-not $HealthUrl) { return $null }
94
+ if ($HealthUrl -notmatch '^http://127\.0\.0\.1:\d+$') { return $null }
95
+ $result = [pscustomobject]@{ reachable = $false; healthz = $false; readyz = $false; error = $null }
96
+ try {
97
+ $healthzResponse = Invoke-WebRequest -Uri "$HealthUrl/healthz" -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop
98
+ $result.healthz = $healthzResponse.StatusCode -eq 200
99
+ $result.reachable = $true
100
+ } catch {
101
+ $result.error = "Health endpoint unreachable: $($_.Exception.Message)"
102
+ return $result
103
+ }
104
+ try {
105
+ $readyzResponse = Invoke-WebRequest -Uri "$HealthUrl/readyz" -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop
106
+ $result.readyz = $readyzResponse.StatusCode -eq 200
107
+ } catch {
108
+ $result.error = "readyz probe failed: $($_.Exception.Message)"
109
+ }
110
+ return $result
111
+ }
112
+
113
+ function Get-PidFromFile {
114
+ param([string]$PidFilePath)
115
+ $raw = Read-FileLineSafe -Path $PidFilePath
116
+ if (-not $raw) { return $null }
117
+ $parsed = 0
118
+ if ([int]::TryParse($raw, [ref]$parsed) -and $parsed -gt 0) { return $parsed }
119
+ return $null
120
+ }
121
+
122
+ function Test-TunnelProcessForMode {
123
+ param($Process, $Definition)
124
+ if (-not $Process -or [string]$Process.Name -ine "tunnel-client.exe") { return $false }
125
+ $commandLine = [string]$Process.CommandLine
126
+ $profilePattern = '(?i)"?--profile"?\s+"?' + [Regex]::Escape($Definition.Profile) + '(?:"|\s|$)'
127
+ $profileEqualsPattern = '(?i)"?--profile"?="?' + [Regex]::Escape($Definition.Profile) + '(?:"|\s|$)'
128
+ $profilePath = Join-Path $env:APPDATA "tunnel-client\$($Definition.Profile).yaml"
129
+ $normalizedCommand = $commandLine -replace '\\', '/'
130
+ $normalizedProfilePath = $profilePath -replace '\\', '/'
131
+ return $commandLine -match '(?i)\brun\b' -and (
132
+ $commandLine -match $profilePattern -or
133
+ $commandLine -match $profileEqualsPattern -or
134
+ $normalizedCommand.IndexOf($normalizedProfilePath, [StringComparison]::OrdinalIgnoreCase) -ge 0
135
+ )
136
+ }
137
+
138
+ function Get-MatchingTunnelProcesses {
139
+ param($Definition)
140
+ return @(
141
+ Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
142
+ Where-Object { Test-TunnelProcessForMode -Process $_ -Definition $Definition }
143
+ )
144
+ }
145
+
146
+ function Get-ModeStatus {
147
+ param($Definition)
148
+ $statusPath = Join-Path $Definition.RuntimeDirectory "tunnel-status.json"
149
+ $healthUrlFile = Join-Path $Definition.RuntimeDirectory "tunnel-health-url.txt"
150
+ $pidFilePath = Join-Path $Definition.RuntimeDirectory "tunnel-client.pid"
151
+ $state = Read-JsonFileSafe -Path $statusPath
152
+
153
+ # Tier 1: Health endpoint probe (highest priority)
154
+ $healthUrl = Read-FileLineSafe -Path $healthUrlFile
155
+ $healthProbe = $null
156
+ foreach ($candidateUrl in @($healthUrl, $Definition.HealthBaseUrl) | Select-Object -Unique) {
157
+ if (-not $candidateUrl) { continue }
158
+ $candidateProbe = Test-TunnelHealthEndpoint -HealthUrl $candidateUrl
159
+ if ($candidateProbe -and $candidateProbe.reachable) {
160
+ $healthProbe = $candidateProbe
161
+ break
162
+ }
163
+ }
164
+ $healthAlive = [bool]($healthProbe -and $healthProbe.reachable -and $healthProbe.healthz)
165
+ $healthReady = [bool]($healthProbe -and $healthProbe.readyz)
166
+
167
+ # Tier 2: PID file to process check
168
+ $pidFromFile = Get-PidFromFile -PidFilePath $pidFilePath
169
+ $pidProcess = if ($pidFromFile) { Get-ProcessByIdSafe -ProcessId $pidFromFile } else { $null }
170
+ $pidProcessAlive = Test-TunnelProcessForMode -Process $pidProcess -Definition $Definition
171
+
172
+ # Tier 3: JSON PID to process check (existing)
173
+ $jsonPid = if ($state -and $state.pid) { [int]$state.pid } else { $null }
174
+ $jsonProcess = if ($jsonPid) { Get-ProcessByIdSafe -ProcessId $jsonPid } else { $null }
175
+ $jsonProcessAlive = Test-TunnelProcessForMode -Process $jsonProcess -Definition $Definition
176
+
177
+ # Tier 4: Exact process scan for orphaned profile runs
178
+ $matchingProcesses = @(Get-MatchingTunnelProcesses -Definition $Definition)
179
+ $scannedProcess = $matchingProcesses | Select-Object -First 1
180
+ $scannedProcessAlive = [bool]$scannedProcess
181
+
182
+ # Best-effort PID
183
+ $bestPid = if ($pidProcessAlive) { $pidFromFile } elseif ($jsonProcessAlive) { $jsonPid } elseif ($scannedProcessAlive) { [int]$scannedProcess.ProcessId } else { $null }
184
+ $bestProcessAlive = $pidProcessAlive -or $jsonProcessAlive -or $scannedProcessAlive
185
+
186
+ # Resolve status and readiness
187
+ $reportedStatus = if ($state -and $state.status) { [string]$state.status } else { "not_started" }
188
+
189
+ if ($healthAlive) {
190
+ # Health endpoint wins: override any stale JSON
191
+ $effectiveStatus = "running"
192
+ $effectiveReady = $healthReady
193
+ $effectiveReasonCode = if ($healthReady) { "health_endpoint_ready" } else { "health_endpoint_alive" }
194
+ $effectiveLastError = $null
195
+ } elseif ($bestProcessAlive) {
196
+ # Process is alive but health endpoint is not reachable
197
+ $effectiveStatus = if ($reportedStatus -eq "not_started") { "running" } else { $reportedStatus }
198
+ $effectiveReady = [bool]($state -and $state.ready)
199
+ $effectiveReasonCode = if ($state) { $state.reason_code } else { "profile_process_found" }
200
+ $effectiveLastError = if ($state) { $state.last_error } else { $null }
201
+ } elseif ($reportedStatus -eq "not_started") {
202
+ $effectiveStatus = "not_started"
203
+ $effectiveReady = $false
204
+ $effectiveReasonCode = if ($state) { $state.reason_code } else { $null }
205
+ $effectiveLastError = if ($state) { $state.last_error } else { $null }
206
+ } else {
207
+ $effectiveStatus = "stopped"
208
+ $effectiveReady = $false
209
+ $effectiveReasonCode = if ($state) { $state.reason_code } else { $null }
210
+ $effectiveLastError = if ($state) { $state.last_error } else { $null }
211
+ }
212
+
213
+ return [pscustomobject]@{
214
+ mode = $Definition.Key
215
+ label = $Definition.Label
216
+ status = $effectiveStatus
217
+ ready = $effectiveReady
218
+ process_alive = [bool]$bestProcessAlive
219
+ health_alive = $healthAlive
220
+ health_ready = $healthReady
221
+ pid = $bestPid
222
+ tool_profile = if ($state) { $state.tool_profile } else { $Definition.ToolProfile }
223
+ tool_count = if ($state) { $state.tool_count } else { $null }
224
+ tools_ready = [bool](($bestProcessAlive -or $healthReady) -and $state -and $state.tools_ready)
225
+ reason_code = $effectiveReasonCode
226
+ last_error = $effectiveLastError
227
+ checked_at = if ($state) { $state.checked_at } else { $null }
228
+ runtime_directory = $Definition.RuntimeDirectory
229
+ }
230
+ }
231
+
232
+ function Show-Status {
233
+ param([string]$SelectedMode)
234
+ $rows = @(Get-SelectedModes -SelectedMode $SelectedMode | ForEach-Object { Get-ModeStatus -Definition $_ })
235
+ if ($Json) {
236
+ $rows | ConvertTo-Json -Depth 4
237
+ return
238
+ }
239
+
240
+ Write-Host ""
241
+ Write-Host "PatchWarden runtime status" -ForegroundColor Cyan
242
+ $rows | Select-Object mode, status, ready, health_alive, process_alive, tool_profile, tool_count, tools_ready | Format-Table -AutoSize
243
+ foreach ($row in $rows) {
244
+ if ($row.last_error) {
245
+ Write-Host "[$($row.mode)] $($row.last_error)" -ForegroundColor Yellow
246
+ }
247
+ if ($row.ready -and $row.health_alive -and -not $row.process_alive) {
248
+ Write-Host "[$($row.mode)] Health endpoint ready; tunnel is alive but PID tracking may be stale." -ForegroundColor DarkCyan
249
+ }
250
+ }
251
+ }
252
+
253
+ function Test-ProjectCommandLine {
254
+ param([string]$CommandLine)
255
+ if (-not $CommandLine) { return $false }
256
+ return $CommandLine.IndexOf($ProjectRoot, [StringComparison]::OrdinalIgnoreCase) -ge 0
257
+ }
258
+
259
+ function Get-OwnedProcesses {
260
+ param($Definition)
261
+
262
+ $allProcesses = @(Get-CimInstance Win32_Process)
263
+ $byPid = @{}
264
+ foreach ($process in $allProcesses) { $byPid[[int]$process.ProcessId] = $process }
265
+
266
+ $seedIds = [System.Collections.Generic.HashSet[int]]::new()
267
+ $status = Read-JsonFileSafe -Path (Join-Path $Definition.RuntimeDirectory "tunnel-status.json")
268
+ if ($status -and $status.pid) {
269
+ $candidate = $byPid[[int]$status.pid]
270
+ if (Test-TunnelProcessForMode -Process $candidate -Definition $Definition) {
271
+ [void]$seedIds.Add([int]$candidate.ProcessId)
272
+ }
273
+ }
274
+
275
+ if ($Definition.HasWatcher) {
276
+ $watcherState = Read-JsonFileSafe -Path (Join-Path $Definition.RuntimeDirectory "watcher-status.json")
277
+ if ($watcherState -and $watcherState.pid) {
278
+ $watcher = $byPid[[int]$watcherState.pid]
279
+ if ($watcher -and (Test-ProjectCommandLine -CommandLine ([string]$watcher.CommandLine)) -and [string]$watcher.CommandLine -match '(?i)dist[\\/]runner[\\/]watch\.js') {
280
+ [void]$seedIds.Add([int]$watcher.ProcessId)
281
+ }
282
+ }
283
+ if ($watcherState -and $watcherState.launcher_pid) {
284
+ $launcher = $byPid[[int]$watcherState.launcher_pid]
285
+ if ($launcher -and (Test-ProjectCommandLine -CommandLine ([string]$launcher.CommandLine)) -and [string]$launcher.CommandLine -match '(?i)start-patchwarden-tunnel\.ps1') {
286
+ [void]$seedIds.Add([int]$launcher.ProcessId)
287
+ }
288
+ }
289
+ }
290
+
291
+ $ownedIds = [System.Collections.Generic.HashSet[int]]::new()
292
+ foreach ($seedId in $seedIds) {
293
+ [void]$ownedIds.Add($seedId)
294
+ $current = $byPid[$seedId]
295
+ while ($current -and [int]$current.ParentProcessId -gt 0) {
296
+ $parent = $byPid[[int]$current.ParentProcessId]
297
+ if (-not $parent) { break }
298
+ $parentCommand = [string]$parent.CommandLine
299
+ if ((Test-ProjectCommandLine -CommandLine $parentCommand) -and $parentCommand -match '(?i)(start-patchwarden-tunnel\.ps1|Start-PatchWarden(?:-Direct)?-Tunnel(?:\.local)?\.cmd)') {
300
+ [void]$ownedIds.Add([int]$parent.ProcessId)
301
+ $current = $parent
302
+ continue
303
+ }
304
+ break
305
+ }
306
+ }
307
+
308
+ $changed = $true
309
+ while ($changed) {
310
+ $changed = $false
311
+ foreach ($process in $allProcesses) {
312
+ if ($ownedIds.Contains([int]$process.ParentProcessId) -and -not $ownedIds.Contains([int]$process.ProcessId)) {
313
+ [void]$ownedIds.Add([int]$process.ProcessId)
314
+ $changed = $true
315
+ }
316
+ }
317
+ }
318
+
319
+ $owned = @()
320
+ foreach ($ownedId in $ownedIds) {
321
+ if ($ownedId -eq $PID) { continue }
322
+ $process = $byPid[$ownedId]
323
+ if (-not $process) { continue }
324
+ $owned += [pscustomobject]@{
325
+ pid = [int]$process.ProcessId
326
+ parent_pid = [int]$process.ParentProcessId
327
+ name = [string]$process.Name
328
+ command = [string]$process.CommandLine
329
+ }
330
+ }
331
+ return $owned
332
+ }
333
+
334
+ function Convert-ToProcessEntry {
335
+ param($Process)
336
+ return [pscustomobject]@{
337
+ pid = [int]$Process.ProcessId
338
+ parent_pid = [int]$Process.ParentProcessId
339
+ name = [string]$Process.Name
340
+ command = [string]$Process.CommandLine
341
+ }
342
+ }
343
+
344
+ function Get-ModeProcesses {
345
+ param($Definition, [switch]$IncludeLaunchers)
346
+ $entries = @()
347
+ $entries += @(Get-OwnedProcesses -Definition $Definition)
348
+ $entries += @(Get-MatchingTunnelProcesses -Definition $Definition | ForEach-Object { Convert-ToProcessEntry -Process $_ })
349
+
350
+ $allProcesses = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue)
351
+ foreach ($process in $allProcesses) {
352
+ if ([int]$process.ProcessId -eq $PID) { continue }
353
+ $commandLine = [string]$process.CommandLine
354
+ if (-not (Test-ProjectCommandLine -CommandLine $commandLine)) { continue }
355
+ $isWatcher = $IncludeLaunchers -and $Definition.HasWatcher -and $commandLine -match '(?i)dist[\\/]runner[\\/]watch\.js'
356
+ $isDirectLauncher = $commandLine -match '(?i)(patchwarden-direct|chatgpt_direct|Start-PatchWarden-Direct-Tunnel)'
357
+ $isTunnelLauncher = $commandLine -match '(?i)(start-patchwarden-tunnel\.ps1|Start-PatchWarden(?:-Direct)?-Tunnel(?:\.local)?\.cmd)'
358
+ $isModeLauncher = $isTunnelLauncher -and (
359
+ ($Definition.Key -eq "direct" -and $isDirectLauncher) -or
360
+ ($Definition.Key -eq "core" -and -not $isDirectLauncher)
361
+ )
362
+ if ($isWatcher -or $isModeLauncher) {
363
+ $entries += Convert-ToProcessEntry -Process $process
364
+ }
365
+ }
366
+
367
+ $uniqueByPid = @{}
368
+ foreach ($entry in $entries) {
369
+ if ($entry.pid -ne $PID) { $uniqueByPid[[int]$entry.pid] = $entry }
370
+ }
371
+ return @($uniqueByPid.Values | Sort-Object pid)
372
+ }
373
+
374
+ function Get-PortOwners {
375
+ param($Definition)
376
+ $port = ([Uri]$Definition.HealthBaseUrl).Port
377
+ $connections = @(
378
+ Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue |
379
+ Sort-Object OwningProcess -Unique
380
+ )
381
+ return @(
382
+ foreach ($connection in $connections) {
383
+ $process = Get-ProcessByIdSafe -ProcessId ([int]$connection.OwningProcess)
384
+ [pscustomobject]@{
385
+ address = "127.0.0.1:$port"
386
+ pid = [int]$connection.OwningProcess
387
+ name = if ($process) { [string]$process.Name } else { "unknown" }
388
+ process = $process
389
+ patchwarden_tunnel = [bool]($process -and (Test-TunnelProcessForMode -Process $process -Definition $Definition))
390
+ }
391
+ }
392
+ )
393
+ }
394
+
395
+ function Assert-NoUnsafePortConflicts {
396
+ param([object[]]$Definitions)
397
+ $conflicts = @()
398
+ foreach ($definition in $Definitions) {
399
+ $conflicts += @(Get-PortOwners -Definition $definition | Where-Object { -not $_.patchwarden_tunnel })
400
+ }
401
+ if ($conflicts.Count -eq 0) { return }
402
+ foreach ($conflict in $conflicts) {
403
+ Write-Host "[conflict] $($conflict.address) is occupied by PID $($conflict.pid) process $($conflict.name)." -ForegroundColor Red
404
+ Write-Host " Please close it or choose another HealthListenAddr." -ForegroundColor Red
405
+ }
406
+ throw "Unsafe health-port conflict detected; no PatchWarden process was stopped or restarted."
407
+ }
408
+
409
+ function Set-StoppedRuntimeState {
410
+ param($Definition)
411
+ if ($WhatIf) { return }
412
+
413
+ New-Item -ItemType Directory -Path $Definition.RuntimeDirectory -Force | Out-Null
414
+ $statusPath = Join-Path $Definition.RuntimeDirectory "tunnel-status.json"
415
+ $state = Read-JsonFileSafe -Path $statusPath
416
+ if (-not $state) { $state = [pscustomobject]@{} }
417
+ $state | Add-Member -NotePropertyName status -NotePropertyValue "stopped" -Force
418
+ $state | Add-Member -NotePropertyName reason_code -NotePropertyValue "stopped_by_manager" -Force
419
+ $state | Add-Member -NotePropertyName ready -NotePropertyValue $false -Force
420
+ $state | Add-Member -NotePropertyName pid -NotePropertyValue $null -Force
421
+ $state | Add-Member -NotePropertyName checked_at -NotePropertyValue ((Get-Date).ToUniversalTime().ToString("o")) -Force
422
+ $state | Add-Member -NotePropertyName next_retry_at -NotePropertyValue $null -Force
423
+ $state | Add-Member -NotePropertyName last_error -NotePropertyValue $null -Force
424
+ $state | Add-Member -NotePropertyName last_exit_code -NotePropertyValue $null -Force
425
+ $state | Add-Member -NotePropertyName stdout_tail -NotePropertyValue @() -Force
426
+ $state | Add-Member -NotePropertyName stderr_tail -NotePropertyValue @() -Force
427
+ $state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $statusPath -Encoding UTF8
428
+
429
+ foreach ($name in @("tunnel-health-url.txt", "tunnel-client.pid", "watcher-status.json")) {
430
+ $path = Join-Path $Definition.RuntimeDirectory $name
431
+ if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue }
432
+ }
433
+ foreach ($path in @($Definition.LegacyPidFile, $Definition.LegacyHealthUrlFile)) {
434
+ if (Test-Path -LiteralPath $path) {
435
+ Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
436
+ Write-Host " Removed stale runtime file: $path" -ForegroundColor DarkGray
437
+ }
438
+ }
439
+
440
+ if ($Definition.HasWatcher) {
441
+ try {
442
+ $configPath = if ($env:PATCHWARDEN_CONFIG) { $env:PATCHWARDEN_CONFIG } else { Join-Path $ProjectRoot "patchwarden.config.json" }
443
+ if (Test-Path -LiteralPath $configPath) {
444
+ $config = Get-Content -LiteralPath $configPath -Raw -Encoding UTF8 | ConvertFrom-Json
445
+ $tasksDirectory = Join-Path $config.workspaceRoot $config.tasksDir
446
+ $heartbeatPath = Join-Path (Split-Path -Parent $tasksDirectory) "watcher-heartbeat.json"
447
+ if (Test-Path -LiteralPath $heartbeatPath) {
448
+ Remove-Item -LiteralPath $heartbeatPath -Force -ErrorAction SilentlyContinue
449
+ }
450
+ }
451
+ } catch {
452
+ Write-Warning "Could not clear the Core watcher heartbeat: $($_.Exception.Message)"
453
+ }
454
+ }
455
+ }
456
+
457
+ function Stop-Mode {
458
+ param($Definition, [switch]$ForceCleanup)
459
+ $verb = if ($ForceCleanup) { "kill" } else { "stop" }
460
+ Write-Host "[$verb`:$($Definition.Key)] Looking for PatchWarden-scoped processes..." -ForegroundColor Yellow
461
+ $owned = @(Get-ModeProcesses -Definition $Definition -IncludeLaunchers:$ForceCleanup)
462
+ if ($owned.Count -eq 0) {
463
+ Write-Host " No matching $($Definition.Label) process is running." -ForegroundColor Green
464
+ Set-StoppedRuntimeState -Definition $Definition
465
+ return
466
+ }
467
+
468
+ $ownedIds = [System.Collections.Generic.HashSet[int]]::new()
469
+ foreach ($process in $owned) { [void]$ownedIds.Add([int]$process.pid) }
470
+ $ordered = @($owned | Sort-Object @{ Expression = { if ($ownedIds.Contains([int]$_.parent_pid)) { 1 } else { 0 } }; Descending = $true }, pid)
471
+ foreach ($entry in $ordered) {
472
+ if ($WhatIf) {
473
+ Write-Host " WHAT-IF: stop PID $($entry.pid) ($($entry.name))" -ForegroundColor Magenta
474
+ continue
475
+ }
476
+ try {
477
+ $process = Get-Process -Id $entry.pid -ErrorAction Stop
478
+ $process.Kill()
479
+ [void]$process.WaitForExit($KillTimeoutSeconds * 1000)
480
+ Write-Host " Stopped PID $($entry.pid) ($($entry.name))." -ForegroundColor Green
481
+ } catch {
482
+ Write-Host " PID $($entry.pid) already exited or is inaccessible." -ForegroundColor DarkGray
483
+ }
484
+ }
485
+ Set-StoppedRuntimeState -Definition $Definition
486
+ }
487
+
488
+ function Invoke-Build {
489
+ if ($SkipBuild) {
490
+ Write-Host "[build] Skipped." -ForegroundColor DarkYellow
491
+ return
492
+ }
493
+ if ($WhatIf) {
494
+ Write-Host "[build] WHAT-IF: npm.cmd run build" -ForegroundColor Magenta
495
+ return
496
+ }
497
+ Write-Host "[build] Building PatchWarden..." -ForegroundColor Yellow
498
+ Push-Location $ProjectRoot
499
+ try {
500
+ & npm.cmd run build
501
+ if ($LASTEXITCODE -ne 0) { throw "npm.cmd run build exited with code $LASTEXITCODE" }
502
+ Write-Host "[build] Build passed." -ForegroundColor Green
503
+ } finally {
504
+ Pop-Location
505
+ }
506
+ }
507
+
508
+ function Start-Mode {
509
+ param($Definition)
510
+ $current = Get-ModeStatus -Definition $Definition
511
+ if ($current.health_alive -and $current.ready) {
512
+ $pidSuffix = if ($current.pid) { " (PID $($current.pid))" } else { "" }
513
+ Write-Host "[start:$($Definition.Key)] Already running and ready$pidSuffix." -ForegroundColor Green
514
+ return
515
+ }
516
+ if ($current.process_alive) {
517
+ throw "[conflict] Profile $($Definition.Profile) already has PID $($current.pid), but it is not ready. Use restart $($Definition.Key) or kill $($Definition.Key)."
518
+ }
519
+ Assert-NoUnsafePortConflicts -Definitions @($Definition)
520
+
521
+ $launcher = if (Test-Path -LiteralPath $Definition.LocalLauncher) { $Definition.LocalLauncher } else { $Definition.GenericLauncher }
522
+ if (-not (Test-Path -LiteralPath $launcher)) { throw "Launcher not found: $launcher" }
523
+ $launcherName = Split-Path -Leaf $launcher
524
+ if ($WhatIf) {
525
+ Write-Host "[start:$($Definition.Key)] WHAT-IF: open $launcherName" -ForegroundColor Magenta
526
+ return
527
+ }
528
+
529
+ $escapedRoot = $ProjectRoot.Replace("'", "''")
530
+ $escapedLauncher = $launcher.Replace("'", "''")
531
+ $command = "Set-Location '$escapedRoot'; & '$escapedLauncher'"
532
+ Start-Process powershell.exe -ArgumentList @("-NoProfile", "-NoExit", "-Command", $command)
533
+ Write-Host "[start:$($Definition.Key)] Opened $launcherName in a separate window." -ForegroundColor Green
534
+ }
535
+
536
+ function Invoke-DeepHealth {
537
+ $healthScript = Join-Path $PSScriptRoot "get-patchwarden-health.ps1"
538
+ if (-not (Test-Path -LiteralPath $healthScript)) { throw "Health script not found: $healthScript" }
539
+ if ($WhatIf) {
540
+ Write-Host "[health] WHAT-IF: run the detailed health check." -ForegroundColor Magenta
541
+ return
542
+ }
543
+ & $healthScript
544
+ Show-Status -SelectedMode "all"
545
+ }
546
+
547
+ function Reset-TunnelKey {
548
+ $launcherScript = Join-Path $PSScriptRoot "start-patchwarden-tunnel.ps1"
549
+ if ($WhatIf) {
550
+ Write-Host "[reset-key] WHAT-IF: remove the DPAPI-encrypted Tunnel runtime credential." -ForegroundColor Magenta
551
+ return
552
+ }
553
+ & $launcherScript -ForgetSavedApiKey
554
+ }
555
+
556
+ function Invoke-ControlAction {
557
+ param([string]$SelectedAction, [string]$SelectedMode)
558
+ $definitions = @(Get-SelectedModes -SelectedMode $SelectedMode)
559
+ switch ($SelectedAction) {
560
+ "status" { Show-Status -SelectedMode $SelectedMode }
561
+ "health" { Invoke-DeepHealth }
562
+ "reset-key" { Reset-TunnelKey }
563
+ "start" {
564
+ Assert-NoUnsafePortConflicts -Definitions $definitions
565
+ foreach ($definition in $definitions) { Start-Mode -Definition $definition }
566
+ }
567
+ "stop" { foreach ($definition in $definitions) { Stop-Mode -Definition $definition } }
568
+ "kill" { foreach ($definition in $definitions) { Stop-Mode -Definition $definition -ForceCleanup } }
569
+ "restart" {
570
+ Assert-NoUnsafePortConflicts -Definitions $definitions
571
+ foreach ($definition in $definitions) { Stop-Mode -Definition $definition }
572
+ Invoke-Build
573
+ foreach ($definition in $definitions) { Start-Mode -Definition $definition }
574
+ }
575
+ default { throw "Unsupported action: $SelectedAction" }
576
+ }
577
+ }
578
+
579
+ function Show-Menu {
580
+ while ($true) {
581
+ Clear-Host
582
+ Write-Host "========================================" -ForegroundColor Cyan
583
+ Write-Host " PatchWarden Control" -ForegroundColor Cyan
584
+ Write-Host "========================================" -ForegroundColor Cyan
585
+ Show-Status -SelectedMode "all"
586
+ Write-Host ""
587
+ Write-Host " 1. Start Core Agent"
588
+ Write-Host " 2. Start Direct"
589
+ Write-Host " 3. Start both"
590
+ Write-Host " 4. Restart Core Agent"
591
+ Write-Host " 5. Restart Direct"
592
+ Write-Host " 6. Restart both"
593
+ Write-Host " 7. Stop Core Agent"
594
+ Write-Host " 8. Stop Direct"
595
+ Write-Host " 9. Stop both"
596
+ Write-Host " 10. Refresh status"
597
+ Write-Host " 11. Detailed health check"
598
+ Write-Host " 12. Reset saved Tunnel API key"
599
+ Write-Host " 13. Force-clean PatchWarden tunnels and watchers"
600
+ Write-Host " 0. Exit"
601
+ Write-Host ""
602
+ $choice = Read-Host "Choose"
603
+ if ($choice -eq "0") { return }
604
+ $selection = switch ($choice) {
605
+ "1" { @("start", "core") }
606
+ "2" { @("start", "direct") }
607
+ "3" { @("start", "all") }
608
+ "4" { @("restart", "core") }
609
+ "5" { @("restart", "direct") }
610
+ "6" { @("restart", "all") }
611
+ "7" { @("stop", "core") }
612
+ "8" { @("stop", "direct") }
613
+ "9" { @("stop", "all") }
614
+ "10" { @("status", "all") }
615
+ "11" { @("health", "all") }
616
+ "12" { @("reset-key", "all") }
617
+ "13" { @("kill", "all") }
618
+ default { $null }
619
+ }
620
+ if (-not $selection) {
621
+ Write-Host "Unknown choice." -ForegroundColor Yellow
622
+ Start-Sleep -Seconds 1
623
+ continue
624
+ }
625
+ try {
626
+ Invoke-ControlAction -SelectedAction $selection[0] -SelectedMode $selection[1]
627
+ } catch {
628
+ Write-Host "Operation failed: $($_.Exception.Message)" -ForegroundColor Red
629
+ }
630
+ Write-Host ""
631
+ Read-Host "Press Enter to return to the menu" | Out-Null
632
+ }
633
+ }
634
+
635
+ if ($Action -eq "menu") {
636
+ Show-Menu
637
+ } else {
638
+ Invoke-ControlAction -SelectedAction $Action -SelectedMode $Mode
639
+ }