@ran-sh/dsh-crew 1.8.0 → 1.9.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 (151) hide show
  1. package/.claude-plugin/marketplace.json +16 -16
  2. package/.claude-plugin/plugin.json +14 -14
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +126 -126
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +116 -116
  19. package/agents/ds-flash.md +24 -24
  20. package/agents/ds-pro.md +24 -24
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +23 -23
  23. package/bin/dsh-crew.mjs +16 -16
  24. package/codex/agents/ds-flash.toml +34 -34
  25. package/codex/agents/ds-pro.toml +34 -34
  26. package/codex/agents/ds-reviewer.toml +32 -32
  27. package/codex/agents/ds-worker.toml +32 -32
  28. package/codex/prompts/dsh-config.md +19 -19
  29. package/codex/prompts/dsh-status.md +5 -5
  30. package/commands/config.md +23 -23
  31. package/commands/off.md +5 -5
  32. package/commands/on.md +5 -5
  33. package/commands/status.md +9 -9
  34. package/cordis.patch.yml +4 -4
  35. package/docs/gpt-relay-extension.md +103 -103
  36. package/docs/installation.md +138 -138
  37. package/docs/job-contracts.md +103 -103
  38. package/docs/readiness-matrix.md +85 -85
  39. package/docs/ui-surfaces.md +107 -107
  40. package/official-web-bridge/cordis.patch.yml +4 -4
  41. package/official-web-bridge/entry.mjs +1 -1
  42. package/official-web-bridge/overlay-entry.mjs +59 -59
  43. package/official-web-bridge/package.json +25 -25
  44. package/package.json +1 -1
  45. package/scripts/build-client.mjs +49 -49
  46. package/scripts/live-crew-smoke.mjs +39 -39
  47. package/scripts/live-policy-matrix.mjs +177 -177
  48. package/scripts/policy-probe.mjs +101 -101
  49. package/scripts/remove-legacy-official-bridge.ps1 +89 -89
  50. package/scripts/setup.mjs +393 -393
  51. package/scripts/smoke-real.mjs +110 -110
  52. package/scripts/smoke.mjs +78 -78
  53. package/scripts/verify-crew-ui-polish.mjs +145 -145
  54. package/scripts/verify-history-ui.mjs +97 -97
  55. package/scripts/verify-installer-fix.mjs +26 -26
  56. package/scripts/verify-npm-install.mjs +311 -311
  57. package/scripts/verify-official-bridge-e2e.mjs +192 -192
  58. package/src/adaptive-routing.mjs +260 -260
  59. package/src/client/activation-summary.tsx +64 -64
  60. package/src/client/collapsible-sections.mjs +55 -55
  61. package/src/client/history-panel.tsx +108 -108
  62. package/src/client/host-readiness.mjs +71 -71
  63. package/src/client/index.tsx +1711 -1711
  64. package/src/client/model-callability-view.mjs +17 -17
  65. package/src/client/panel-chrome.tsx +40 -40
  66. package/src/client/quick-entry.tsx +10 -10
  67. package/src/client/quick-panel.tsx +275 -275
  68. package/src/client/readiness-envelope.mjs +34 -34
  69. package/src/client/surface-detection.mjs +43 -43
  70. package/src/config-readiness.mjs +226 -226
  71. package/src/credential-reference.mjs +38 -38
  72. package/src/delivery.mjs +205 -205
  73. package/src/dsh-cli-runtime.mjs +1021 -1021
  74. package/src/dsh-cohort.mjs +20 -20
  75. package/src/extension-contract.mjs +104 -104
  76. package/src/failure-classification.mjs +201 -201
  77. package/src/history/admission-gate.mjs +67 -67
  78. package/src/history/archive-store.mjs +272 -272
  79. package/src/history/cleanup-plan.mjs +89 -89
  80. package/src/history/http.mjs +32 -32
  81. package/src/history/operation.mjs +86 -86
  82. package/src/history/runner-detach.mjs +34 -34
  83. package/src/history/runner.mjs +52 -52
  84. package/src/history/runtime.mjs +36 -36
  85. package/src/history/service.mjs +177 -177
  86. package/src/history/state.mjs +27 -27
  87. package/src/hub/entry.mjs +104 -104
  88. package/src/hub/index.mjs +2694 -2694
  89. package/src/hub-client.mjs +154 -154
  90. package/src/hub-compatibility.mjs +42 -42
  91. package/src/i18n.mjs +19 -19
  92. package/src/information-flow.mjs +67 -67
  93. package/src/install/cli.mjs +28 -28
  94. package/src/install/install-legacy.mjs +711 -711
  95. package/src/install/install.mjs +483 -483
  96. package/src/install/npx-lifecycle.mjs +3456 -3456
  97. package/src/install/official-frontend-assets.mjs +78 -78
  98. package/src/install/official-web.mjs +95 -95
  99. package/src/install/payload-content.mjs +88 -88
  100. package/src/install/windows-startup.mjs +236 -236
  101. package/src/install/windows-supervisor-adapter.mjs +443 -443
  102. package/src/install/windows-supervisor-lifecycle.mjs +782 -782
  103. package/src/install/zcode.mjs +397 -397
  104. package/src/job-contracts.mjs +255 -255
  105. package/src/local-request-guard.mjs +60 -60
  106. package/src/mcp-runtime.mjs +340 -332
  107. package/src/model-callability-contract.mjs +79 -79
  108. package/src/model-catalog.mjs +180 -180
  109. package/src/model-routing.mjs +586 -586
  110. package/src/model-schedule.mjs +207 -207
  111. package/src/official-web-bridge.mjs +447 -447
  112. package/src/policy.mjs +235 -235
  113. package/src/provider-delete-adapters.mjs +1934 -1934
  114. package/src/provider-health.mjs +130 -130
  115. package/src/provider-inventory.mjs +182 -182
  116. package/src/provider-layer-migration-adapters.mjs +759 -759
  117. package/src/provider-layer-migration.mjs +198 -198
  118. package/src/provider-lifecycle-state.mjs +103 -103
  119. package/src/provider-lifecycle.mjs +252 -252
  120. package/src/provider-profile-store.mjs +390 -390
  121. package/src/provider-settings-store.mjs +633 -633
  122. package/src/provider-store-lock.mjs +67 -67
  123. package/src/readiness-matrix.mjs +181 -181
  124. package/src/removable-waiter.mjs +29 -29
  125. package/src/role-profiles.mjs +107 -107
  126. package/src/runtime-controls.mjs +84 -84
  127. package/src/runtime-identity-contract.mjs +34 -34
  128. package/src/runtime-identity.mjs +235 -235
  129. package/src/runtime-readiness-snapshot.mjs +285 -285
  130. package/src/server.mjs +581 -581
  131. package/src/session-origins.mjs +60 -60
  132. package/src/standalone-sdk.mjs +23 -23
  133. package/src/status-shard.mjs +63 -63
  134. package/src/structured-error-code.mjs +38 -38
  135. package/src/supervisor/restart-request.mjs +256 -256
  136. package/src/workflow-runtime.mjs +739 -739
  137. package/src/workflow.mjs +155 -155
  138. package/src/workspace-audit.mjs +231 -231
  139. package/src/workspace-context.mjs +146 -146
  140. package/src/workspace-isolation.mjs +455 -401
  141. package/src/workspace-readiness.mjs +32 -32
  142. package/statusline/statusline.sh +14 -14
  143. package/statusline/worker-segment.sh +35 -35
  144. package/windows/start-dsh-crew.cmd +57 -57
  145. package/windows/start-dsh-crew.ps1 +1302 -1302
  146. package/windows/supervisor-control.ps1 +467 -467
  147. package/worker.cordis.yml +67 -67
  148. package/zcode/agents/ds-reviewer.md +31 -31
  149. package/zcode/agents/ds-worker.md +31 -31
  150. package/zcode/commands/dsh-config.md +17 -17
  151. package/zcode/commands/dsh-status.md +5 -5
@@ -1,1302 +1,1302 @@
1
- # DSH Crew managed Windows launcher
2
- [CmdletBinding()]
3
- param(
4
- [ValidateSet('background', 'open', 'watch')]
5
- [string] $Mode = 'open'
6
- )
7
-
8
- Set-StrictMode -Version Latest
9
- $ErrorActionPreference = 'Stop'
10
-
11
- $crewHome = Join-Path $env:USERPROFILE '.config\dsh-crew\harness'
12
- $configuredDshCli = [string] $env:DSH_CREW_DSH_CLI
13
- if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { $configuredDshCli = '' }
14
- if (-not $configuredDshCli.Trim() -and $env:DSH_CREW_LAUNCHER_TEST_IMPORT -ne '1') {
15
- # Crew-managed npm runtime: the normal install path, written by dsh-crew
16
- # update and carrying whatever cohort that release pinned. An explicit
17
- # DSH_CREW_DSH_CLI always wins so an operator can still pin another entry.
18
- $runtimeEntry = Join-Path $crewHome 'runtime\node_modules\@deepseek-ai\dsh\lib\bin.js'
19
- if (Test-Path -LiteralPath $runtimeEntry -PathType Leaf) {
20
- $configuredDshCli = $runtimeEntry
21
- }
22
- }
23
- if (-not $configuredDshCli.Trim() -and $env:DSH_CREW_LAUNCHER_TEST_IMPORT -ne '1') {
24
- # Crew-managed source cohort: a runtime-source-<label>.json sidecar names the
25
- # CLI entry. The sidecar's recorded version must equal the checkout's own
26
- # manifest, so a stale sidecar can never pin a half-updated tree.
27
- foreach ($sidecar in @(Get-ChildItem -LiteralPath $crewHome -Filter 'runtime-source-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
28
- try {
29
- $metadata = Get-Content -LiteralPath $sidecar.FullName -Raw | ConvertFrom-Json
30
- if ($metadata.managed_by -ne 'dsh-crew') { continue }
31
- $candidate = [string] $metadata.cli_entry
32
- if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
33
- $sourceRoot = [IO.Path]::GetFullPath((Join-Path $crewHome ([IO.Path]::GetFileNameWithoutExtension($sidecar.Name))))
34
- $candidateFull = [IO.Path]::GetFullPath($candidate)
35
- $sourcePrefix = $sourceRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
36
- $sourceManifest = Join-Path $sourceRoot 'apps\cli\package.json'
37
- $sourceVersion = if (Test-Path -LiteralPath $sourceManifest -PathType Leaf) {
38
- [string] (Get-Content -LiteralPath $sourceManifest -Raw | ConvertFrom-Json).version
39
- } else { $null }
40
- $candidateValid = $sourceVersion -and $sourceVersion -eq [string] $metadata.version `
41
- -and $candidateFull.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase) `
42
- -and (Test-Path -LiteralPath $candidateFull -PathType Leaf)
43
- if ($candidateValid) {
44
- $configuredDshCli = $candidateFull
45
- break
46
- }
47
- } catch { }
48
- }
49
- }
50
- $dshCli = if ($configuredDshCli.Trim()) {
51
- try { [IO.Path]::GetFullPath($configuredDshCli) } catch { $configuredDshCli }
52
- } else {
53
- Join-Path $crewHome 'runtime\node_modules\.bin\dsh.cmd'
54
- }
55
- if ($configuredDshCli.Trim()) { $env:DSH_CREW_DSH_CLI = $dshCli }
56
- # DSH_HOME that owns the selected CLI. The managed npm runtime shares the Crew
57
- # home; a Crew-managed source cohort is its own home (its profiles, sessions and
58
- # settings live inside that tree). Derived from the selected entry so an explicit
59
- # DSH_CREW_DSH_CLI override resolves correctly too.
60
- $dshHome = $crewHome
61
- $sourceRootMatch = [regex]::Match($dshCli, '^(?<root>.+?[\\/]runtime-source-[^\\/]+)[\\/]')
62
- if ($sourceRootMatch.Success) {
63
- $candidateRoot = $sourceRootMatch.Groups['root'].Value
64
- if (Test-Path -LiteralPath (Join-Path $candidateRoot 'profiles') -PathType Container) { $dshHome = $candidateRoot }
65
- }
66
- $dshCliIsNodeEntry = [IO.Path]::GetExtension($dshCli).ToLowerInvariant() -eq '.js'
67
- $dshCommand = if ($dshCliIsNodeEntry) {
68
- (Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
69
- } else { $dshCli }
70
-
71
- function Get-CrewDshManifest {
72
- param([string] $Entry)
73
- if (-not $Entry) { return $null }
74
- try { $cursor = [IO.Path]::GetFullPath($Entry) } catch { return $null }
75
- if ([IO.Path]::GetExtension($cursor).ToLowerInvariant() -eq '.js') {
76
- $cursor = Split-Path -Parent $cursor
77
- } else {
78
- $cursor = Split-Path -Parent $cursor
79
- }
80
- for ($depth = 0; $depth -lt 12 -and $cursor; $depth += 1) {
81
- $candidates = @(
82
- (Join-Path $cursor 'package.json'),
83
- (Join-Path $cursor 'node_modules\@deepseek-ai\dsh\package.json')
84
- )
85
- foreach ($candidate in $candidates) {
86
- if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue }
87
- try {
88
- $manifest = Get-Content -LiteralPath $candidate -Raw | ConvertFrom-Json
89
- $knownOfficial = -not $configuredDshCli.Trim() -or $manifest.name -eq '@deepseek-ai/dsh'
90
- if ($knownOfficial -and [string] $manifest.version) { return $manifest }
91
- } catch { }
92
- }
93
- $parent = Split-Path -Parent $cursor
94
- if ($parent -eq $cursor) { break }
95
- $cursor = $parent
96
- }
97
- return $null
98
- }
99
-
100
- $dshManifest = Get-CrewDshManifest -Entry $dshCli
101
- $logRoot = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
102
- $launcherLog = Join-Path $logRoot 'dsh-crew-launcher.log'
103
- $startedAt = Get-Date
104
- $launcherProcessStartedAtUtcTicks = try { (Get-Process -Id $PID -ErrorAction Stop).StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture) } catch { $null }
105
- $supervisorInstanceId = [guid]::NewGuid().ToString()
106
- $launcherHelperHash = $null
107
- try {
108
- $hashStream = [System.IO.File]::OpenRead($PSCommandPath)
109
- try {
110
- $sha256 = [System.Security.Cryptography.SHA256]::Create()
111
- try { $launcherHelperHash = (($sha256.ComputeHash($hashStream) | ForEach-Object { $_.ToString('x2') }) -join '') }
112
- finally { $sha256.Dispose() }
113
- } finally { $hashStream.Dispose() }
114
- } catch { $launcherHelperHash = $null }
115
- $services = @(
116
- [pscustomobject]@{ Name = 'Crew backend'; Profile = 'dsh-crew'; Home = $dshHome; Port = 3210; Url = 'http://127.0.0.1:3210'; CrewOwned = $true; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null }
117
- )
118
-
119
- function Write-LaunchLog {
120
- param([string] $Message, [ValidateSet('INFO', 'WARN', 'ERROR')] [string] $Level = 'INFO')
121
- $line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffK'), $Level, $Message
122
- Add-Content -LiteralPath $launcherLog -Value $line -Encoding UTF8
123
- if ($Mode -eq 'open') {
124
- if ($Level -eq 'ERROR') { Write-Host $line -ForegroundColor Red }
125
- elseif ($Level -eq 'WARN') { Write-Host $line -ForegroundColor Yellow }
126
- else { Write-Host $line }
127
- }
128
- }
129
-
130
- function Resolve-OfficialHarnessCommand {
131
- $shim = Get-Command dsh.cmd -CommandType Application -ErrorAction Stop | Select-Object -First 1
132
- $root = Join-Path (Split-Path -Parent $shim.Source) 'node_modules\@deepseek-ai\dsh'
133
- $manifest = Get-Content -LiteralPath (Join-Path $root 'package.json') -Raw | ConvertFrom-Json
134
- if ($manifest.name -ne '@deepseek-ai/dsh') { throw 'The dsh command is not a verified official Harness installation.' }
135
- $entrySpec = [string] $manifest.bin.dsh
136
- if (-not $entrySpec -or [IO.Path]::IsPathRooted($entrySpec) -or $entrySpec.Contains(':') -or ($entrySpec.Replace('\', '/').Split('/') -contains '..')) {
137
- throw 'The official Harness CLI entry is invalid.'
138
- }
139
- $entry = [IO.Path]::GetFullPath((Join-Path $root $entrySpec))
140
- $rootPrefix = [IO.Path]::GetFullPath($root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
141
- if (-not $entry.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase) -or -not (Test-Path -LiteralPath $entry -PathType Leaf)) {
142
- throw 'The official Harness CLI entry was not found.'
143
- }
144
- $node = Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1
145
- return [pscustomobject]@{ NodePath = $node.Source; Entry = $entry }
146
- }
147
-
148
- function Test-OfficialHarnessListener {
149
- param([int] $OwnerPid, [pscustomobject] $Official, [string] $Profile = 'web')
150
- if ($OwnerPid -le 0) { return $false }
151
- try {
152
- $owner = Get-CimInstance Win32_Process -Filter ('ProcessId={0}' -f $OwnerPid) -ErrorAction Stop
153
- if (-not $owner -or [IO.Path]::GetFileName([string] $owner.ExecutablePath) -ine 'node.exe') { return $false }
154
- $line = ([string] $owner.CommandLine).Replace('/', '\')
155
- $entry = [regex]::Escape($Official.Entry.Replace('/', '\'))
156
- $node = [regex]::Escape($Official.NodePath.Replace('/', '\'))
157
- $profileToken = [regex]::Escape($Profile)
158
- $pattern = '^\s*(?:"?' + $node + '"?|"?node(?:\.exe)?"?)\s+"?' + $entry + '"?\s+(?:"?' + $profileToken + '"?|--profile\s+"?' + $profileToken + '"?)(?:\s|$)'
159
- return $line -match $pattern
160
- } catch { return $false }
161
- }
162
-
163
- function Test-CrewDshCliPreflight {
164
- if ($null -eq $dshManifest -or [string]::IsNullOrWhiteSpace([string] $dshManifest.version)) {
165
- throw "The DSH CLI at $dshCli is not a verified @deepseek-ai/dsh package entry."
166
- }
167
- if ($dshCliIsNodeEntry -and -not (Test-Path -LiteralPath (Join-Path (Join-Path $dshHome 'profiles') 'web\package.json') -PathType Leaf)) {
168
- throw ('The Harness web profile is missing under {0}. Run: dsh-crew update' -f $dshHome)
169
- }
170
- $versionArgs = if ($dshCliIsNodeEntry) { @($dshCli, '--version') } else { @('--version') }
171
- $reported = try { (& $dshCommand @versionArgs 2>&1 | Out-String).Trim() } catch { '' }
172
- if ($LASTEXITCODE -ne 0 -or $reported -ne [string] $dshManifest.version) {
173
- throw ('DSH CLI preflight failed: manifest={0}, reported={1}' -f $dshManifest.version, ($reported -replace '\s+', ' '))
174
- }
175
- }
176
-
177
- function Get-CrewWebSessionUrl {
178
- param([string] $OutputLog = $null)
179
- $logs = if ($OutputLog) {
180
- @(Get-Item -LiteralPath $OutputLog -ErrorAction SilentlyContinue)
181
- } else {
182
- @(Get-ChildItem -LiteralPath $logRoot -Filter 'dsh-crew-web-*.out.log' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
183
- }
184
- foreach ($log in $logs) {
185
- try {
186
- $text = Get-Content -LiteralPath $log.FullName -Raw -ErrorAction Stop
187
- $match = [regex]::Match($text, 'https?://127\.0\.0\.1:3080/\?token=[^\s\r\n]+')
188
- if ($match.Success) { return $match.Value }
189
- } catch { }
190
- }
191
- return $null
192
- }
193
-
194
- function Open-CrewBrowserUrl {
195
- param([Parameter(Mandatory = $true)] [string] $Url)
196
- $edge = Get-Command msedge.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
197
- if (-not $edge) {
198
- $edgeCandidates = @(
199
- @(
200
- (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
201
- (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe'),
202
- (Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\Application\msedge.exe')
203
- ) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) }
204
- )
205
- if ($edgeCandidates.Count -gt 0) { $edge = [pscustomobject]@{ Source = $edgeCandidates[0] } }
206
- }
207
- if ($edge) {
208
- Start-Process -FilePath $edge.Source -ArgumentList @('--new-window', $Url) | Out-Null
209
- return
210
- }
211
- Start-Process $Url | Out-Null
212
- }
213
-
214
- function Test-CrewWebSessionUrl {
215
- param([string] $Url)
216
- if (-not $Url) { return $false }
217
- try {
218
- # The token endpoint intentionally answers with a 303 + Set-Cookie and
219
- # redirects to `/`; do not follow it here or PowerShell loses the cookie
220
- # and falsely reports the authenticated page as 404.
221
- $response = Invoke-WebRequest -UseBasicParsing -MaximumRedirection 0 -Uri $Url -TimeoutSec 3
222
- return [int] $response.StatusCode -ge 200 -and [int] $response.StatusCode -lt 400
223
- } catch {
224
- $status = if ($_.Exception.Response) { [int] $_.Exception.Response.StatusCode } else { 0 }
225
- return $status -in @(301, 302, 303, 307, 308)
226
- }
227
- }
228
-
229
- function Open-CrewManagedFrontend {
230
- param([int] $TimeoutSeconds = 90)
231
- # The 3080 frontend boots from the same Crew-managed Harness entry as 3210,
232
- # against that entry's own DSH_HOME (npm runtime -> Crew home, source cohort
233
- # -> its own tree). No official ~/.dsh state is read or written here.
234
- $managedHome = $dshHome
235
- $managedProfile = 'web'
236
- $profileManifest = Join-Path (Join-Path $managedHome 'profiles') ('{0}\package.json' -f $managedProfile)
237
- if (-not $dshCliIsNodeEntry -or -not (Test-Path -LiteralPath $profileManifest -PathType Leaf)) {
238
- return $false
239
- }
240
- $official = [pscustomobject]@{ NodePath = $dshCommand; Entry = $dshCli; Profile = $managedProfile }
241
- $frontend = Get-OfficialFrontendOverlay
242
- $mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
243
- $locked = $false
244
- try {
245
- try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
246
- if (-not $locked) { throw 'Another desktop launch is still starting the Crew-managed Harness frontend.' }
247
- $port = Get-PortState -Port 3080
248
- if ($port.State -eq 'unknown') { throw $port.Error }
249
- if ($port.State -eq 'occupied') {
250
- if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official -Profile $managedProfile)) {
251
- throw 'Port 3080 is occupied by a non-Crew-managed process; it was left untouched.'
252
- }
253
- if (-not (Test-OfficialWebReady)) { throw 'The Crew-managed 3080 frontend is not ready; its process was left running.' }
254
- $existingUrl = Get-CrewWebSessionUrl
255
- if ($existingUrl -and (Test-CrewWebSessionUrl -Url $existingUrl)) {
256
- Open-CrewBrowserUrl -Url $existingUrl
257
- Write-LaunchLog 'Opened the existing Crew-managed Harness frontend on 3080 with its session URL.'
258
- return $true
259
- }
260
- Write-LaunchLog 'The existing Crew-managed 3080 session URL is stale; restarting the verified listener.' 'WARN'
261
- Stop-Process -Id $port.Pid -Force -ErrorAction Stop
262
- $freeDeadline = (Get-Date).AddSeconds(5)
263
- do { Start-Sleep -Milliseconds 250; $port = Get-PortState -Port 3080 } while ($port.State -ne 'free' -and (Get-Date) -lt $freeDeadline)
264
- if ($port.State -ne 'free') { throw 'The stale Crew-managed 3080 listener did not release the port.' }
265
- }
266
-
267
- $stamp = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmssfff'), $PID
268
- $stdout = Join-Path $logRoot ('dsh-crew-web-{0}.out.log' -f $stamp)
269
- $stderr = Join-Path $logRoot ('dsh-crew-web-{0}.err.log' -f $stamp)
270
- $previousHome = $env:DSH_HOME
271
- $previousCli = $env:DSH_CREW_DSH_CLI
272
- try {
273
- $env:DSH_HOME = $managedHome
274
- $env:DSH_CREW_DSH_CLI = $dshCli
275
- $arguments = @($dshCli, '--profile', $managedProfile, '--patch', ('"{0}"' -f $frontend.Path), '--host', '127.0.0.1', '--port', '3080')
276
- $process = Start-Process -FilePath $dshCommand -ArgumentList $arguments -WindowStyle Hidden -PassThru `
277
- -RedirectStandardOutput $stdout -RedirectStandardError $stderr
278
- } finally {
279
- $env:DSH_HOME = $previousHome
280
- $env:DSH_CREW_DSH_CLI = $previousCli
281
- }
282
- Write-LaunchLog ('Started Crew-managed Harness on 3080; PID={0}. Logs: {1}, {2}' -f $process.Id, $stdout, $stderr)
283
- $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
284
- do {
285
- $port = Get-PortState -Port 3080
286
- if ($port.State -eq 'occupied') {
287
- if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official -Profile $managedProfile)) {
288
- throw 'Port 3080 became occupied by an unverified process; it was left untouched.'
289
- }
290
- if (Test-OfficialWebReady) {
291
- $startedUrl = Get-CrewWebSessionUrl -OutputLog $stdout
292
- if ($startedUrl) { Open-CrewBrowserUrl -Url $startedUrl; return $true }
293
- }
294
- }
295
- if ($process.HasExited) { throw ('Crew-managed Harness exited before readiness. Diagnostic log: {0}' -f $stderr) }
296
- Start-Sleep -Milliseconds 500
297
- } while ((Get-Date) -lt $deadline)
298
- throw ('The Crew-managed Harness did not become ready within {0}s; inspect {1}.' -f $TimeoutSeconds, $stderr)
299
- } finally {
300
- if ($locked) { $mutex.ReleaseMutex() }
301
- $mutex.Dispose()
302
- }
303
- }
304
-
305
- function Test-OfficialWebReady {
306
- try {
307
- $response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:3080/' -TimeoutSec 2
308
- return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300
309
- } catch {
310
- # An authenticated official UI may reject this cookie-free health request.
311
- if ($_.Exception.Response) { return [int] $_.Exception.Response.StatusCode -in @(401, 403) }
312
- return $false
313
- }
314
- }
315
-
316
- function Get-OfficialFrontendOverlay {
317
- $frontendRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\frontend'
318
- $path = Join-Path $frontendRoot 'official-web.patch.json'
319
- if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'Crew frontend overlay is missing. Run dsh-crew update with the current GitHub candidate.' }
320
- # Windows PowerShell 5.1 keeps a JSON root array as one pipeline object;
321
- # direct `$patch[0].insert[0]` therefore resolves `insert` as IList.Insert
322
- # instead of indexing the JSON property. Flatten both PowerShell generations
323
- # explicitly and read properties through PSObject so StrictMode reports a
324
- # useful validation error instead of "property id cannot be found".
325
- $parsed = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
326
- $patch = @($parsed | ForEach-Object { $_ })
327
- $root = if ($patch.Count -eq 1) { $patch[0] } else { $null }
328
- $insertProperty = if ($null -ne $root) { $root.PSObject.Properties['insert'] } else { $null }
329
- $insertValue = if ($null -ne $insertProperty) { $insertProperty.Value } else { $null }
330
- $inserts = @($insertValue | ForEach-Object { $_ })
331
- $bridge = if ($inserts.Count -eq 1) { $inserts[0] } else { $null }
332
- $idProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['id'] } else { $null }
333
- $nameProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['name'] } else { $null }
334
- if ($patch.Count -ne 1 -or $inserts.Count -ne 1 -or $null -eq $idProperty -or [string] $idProperty.Value -ne 'dsh-crew-official-web-bridge' -or $null -eq $nameProperty) {
335
- throw 'Crew frontend overlay has an unexpected structure.'
336
- }
337
- $uri = [Uri] ([string] $nameProperty.Value)
338
- if (-not $uri.IsFile) { throw 'Crew frontend must load from its local snapshot.' }
339
- $entry = [IO.Path]::GetFullPath($uri.LocalPath)
340
- $prefix = [IO.Path]::GetFullPath((Join-Path $frontendRoot 'revisions')) + [IO.Path]::DirectorySeparatorChar
341
- if (-not $entry.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Crew frontend snapshot is outside its owned directory.' }
342
- $metadata = Get-Content -LiteralPath (Join-Path (Split-Path -Parent $entry) 'package.json') -Raw | ConvertFrom-Json
343
- $revision = [string] $metadata.dshCrewFrontendRevision
344
- if ($metadata.dshCrewManagedFrontend -ne $true -or $revision -notmatch '^[a-f0-9]{64}$' -or -not (Test-Path -LiteralPath $entry -PathType Leaf)) {
345
- throw 'Crew frontend snapshot is incomplete.'
346
- }
347
- return [pscustomobject]@{ Path = $path; Revision = $revision }
348
- }
349
-
350
- function Test-OfficialFrontendAttached {
351
- param([string] $Revision)
352
- try {
353
- $response = Invoke-RestMethod -Uri 'http://127.0.0.1:3080/_dsh/dsh-crew/bridge-status' -TimeoutSec 2
354
- return $response.ok -eq $true -and $response.surface -eq 'official-bridge' -and $response.frontend_revision -eq $Revision
355
- } catch { return $false }
356
- }
357
-
358
- function Open-OfficialFrontend {
359
- param([int] $TimeoutSeconds = 90)
360
- # Preferred path: the Crew-managed Harness serves 3080 with the Crew panel
361
- # already loaded. The legacy fallback below (separate official install in
362
- # ~/.dsh) is only reached when no Crew-managed frontend is available.
363
- if (Open-CrewManagedFrontend -TimeoutSeconds $TimeoutSeconds) { return }
364
- $official = Resolve-OfficialHarnessCommand
365
- $frontend = Get-OfficialFrontendOverlay
366
- $mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
367
- $locked = $false
368
- try {
369
- try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
370
- if (-not $locked) { throw 'Another desktop launch is still starting the official frontend.' }
371
- $port = Get-PortState -Port 3080
372
- if ($port.State -eq 'unknown') { throw $port.Error }
373
- if ($port.State -eq 'occupied') {
374
- if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 is occupied by an unverified process; it was left untouched.' }
375
- if (-not (Test-OfficialWebReady)) { throw 'The official 3080 frontend is not ready; its process was left running.' }
376
- if (-not (Test-OfficialFrontendAttached -Revision $frontend.Revision)) { throw 'The running official frontend has not loaded the current Crew panel. Stop that official instance when idle, then launch from the desktop again. It was left untouched.' }
377
- Start-Process 'http://127.0.0.1:3080/' | Out-Null
378
- Write-LaunchLog 'Opened the existing official Harness frontend on 3080.'
379
- return
380
- }
381
-
382
- $stamp = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmssfff'), $PID
383
- $stdout = Join-Path $logRoot ('dsh-official-web-{0}.out.log' -f $stamp)
384
- $stderr = Join-Path $logRoot ('dsh-official-web-{0}.err.log' -f $stamp)
385
- $previousHome = $env:DSH_HOME
386
- try {
387
- # The official application owns its normal state; no Crew plugin/profile
388
- # registration or repair is performed in this home by the launcher.
389
- $env:DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
390
- $arguments = @(('"{0}"' -f $official.Entry), 'web', '--patch', ('"{0}"' -f $frontend.Path), '--host', '127.0.0.1', '--port', '3080')
391
- $process = Start-Process -FilePath $official.NodePath -ArgumentList $arguments -WindowStyle Hidden -PassThru `
392
- -RedirectStandardOutput $stdout -RedirectStandardError $stderr
393
- } finally { $env:DSH_HOME = $previousHome }
394
- Write-LaunchLog ('Started official Harness on 3080; PID={0}. The official CLI opens the browser. Logs: {1}, {2}' -f $process.Id, $stdout, $stderr)
395
- $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
396
- $nextProgress = (Get-Date).AddSeconds(5)
397
- do {
398
- $port = Get-PortState -Port 3080
399
- if ($port.State -eq 'occupied') {
400
- if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 became occupied by an unverified process; it was left untouched.' }
401
- if ((Test-OfficialWebReady) -and (Test-OfficialFrontendAttached -Revision $frontend.Revision)) { return }
402
- }
403
- if ($process.HasExited) { throw ('Official Harness exited before readiness. Diagnostic log: {0}' -f $stderr) }
404
- if ((Get-Date) -ge $nextProgress) {
405
- Write-LaunchLog 'Waiting for the official Harness frontend on 3080...'
406
- $nextProgress = (Get-Date).AddSeconds(5)
407
- }
408
- Start-Sleep -Milliseconds 500
409
- } while ((Get-Date) -lt $deadline)
410
- throw ('Official Harness did not become ready within {0}s; inspect {1}. No official process was stopped.' -f $TimeoutSeconds, $stderr)
411
- } finally {
412
- if ($locked) { $mutex.ReleaseMutex() }
413
- $mutex.Dispose()
414
- }
415
- }
416
-
417
- function Get-HealthState {
418
- param([pscustomObject] $Service)
419
- # Preserve the installed extension health contract used by maintenance.
420
- if ($Service.CrewOwned) {
421
- try {
422
- $response = Invoke-RestMethod -Uri ($Service.Url + '/_dsh/dsh-crew/extension') -TimeoutSec 2
423
- $runtime = $response.extension.runtime
424
- $version = $runtime.runtime_version
425
- # A stale 3210 process (booted before the runtime tree was swapped) can
426
- # keep serving from memory while the disk tree is a different cohort.
427
- # Require the reported dsh_version to equal the installed disk
428
- # @deepseek-ai/dsh version so supervisor never treats a
429
- # disk-rc.1/memory-alpha.5 (or vice versa) process as healthy.
430
- # FAIL CLOSED: an unreadable/missing disk manifest is NOT healthy —
431
- # the process cannot be proven to match the tree it will next boot.
432
- $expectedDshVersion = $null
433
- $diskReadable = $false
434
- $activeDshManifest = Get-CrewDshManifest -Entry $dshCli
435
- if ($null -ne $activeDshManifest -and [string] $activeDshManifest.version) {
436
- $expectedDshVersion = [string] $activeDshManifest.version
437
- $diskReadable = $true
438
- }
439
- $cohortMatches = $diskReadable -and $runtime.dsh_version -eq $expectedDshVersion
440
- if ($response.ok -eq $true -and $version -and $cohortMatches) {
441
- return [pscustomobject]@{ Ready = $true; Version = [string] $version; Error = $null }
442
- }
443
- if (-not $diskReadable) {
444
- return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'disk runtime manifest unreadable; cannot prove cohort identity' }
445
- }
446
- if ($version -and -not $cohortMatches) {
447
- return [pscustomobject]@{ Ready = $false; Version = [string] $version; Error = ('runtime cohort mismatch: hub reports dsh_version={0} but disk runtime is {1}' -f $runtime.dsh_version, $expectedDshVersion) }
448
- }
449
- return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'Response did not contain a ready runtime contract.' }
450
- } catch {
451
- return [pscustomobject]@{ Ready = $false; Version = $null; Error = $_.Exception.Message }
452
- }
453
- }
454
- # The launcher supervises only the Crew-owned 3210 service. Any non-Crew
455
- # service is never started or health-gated here; the legacy 3080 bridge
456
- # is probed only diagnostically after 3210 readiness (see Write-LegacyBridgeDiagnostic).
457
- return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'Unknown service: only the Crew-owned 3210 service is supervised.' }
458
- }
459
-
460
- # Optional legacy compatibility probe: returns true only when a 3080 that
461
- # still hosts the Crew bridge answers its ping endpoint. The Crew launcher
462
- # never depends on it; 3210 always boots directly first.
463
- function Test-LegacyBridgeAvailable {
464
- try {
465
- $response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:3080/_dsh/dsh-crew/ping' -TimeoutSec 3
466
- return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300)
467
- } catch {
468
- return $false
469
- }
470
- }
471
-
472
- # ---- Crew supervisor control protocol (heartbeat + restart requests) --------
473
- # The hub (3210) never spawns itself. It writes a durable restart request;
474
- # this watcher is the only process authority and executes it after proving
475
- # ownership (persisted identity + live runtime_id match).
476
-
477
- $crewSupervisorRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\supervisor'
478
- $crewHeartbeatFile = Join-Path $crewSupervisorRoot 'heartbeat.json'
479
- $crewRequestsDir = Join-Path $crewSupervisorRoot 'restart-requests'
480
- $crewResultsDir = Join-Path $crewSupervisorRoot 'restart-results'
481
-
482
- # UTF-8 WITHOUT BOM: Windows PowerShell 5.1's Set-Content -Encoding UTF8
483
- # writes a BOM, and the Node hub's JSON.parse rejects it (heartbeat/request
484
- # files would be unreadable -> supervisor judged unavailable).
485
- function Write-Utf8NoBom {
486
- param([string] $Path, [string] $Content)
487
- $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
488
- [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
489
- }
490
-
491
- function Write-SupervisorHeartbeat {
492
- param([bool] $OwnershipReady = $false)
493
- $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
494
- $record = @{ schema_version = 1; supervisor_instance_id = $supervisorInstanceId; pid = $PID; process_started_at_utc_ticks = $launcherProcessStartedAtUtcTicks; helper_hash = $launcherHelperHash; ownership_ready = $OwnershipReady; last_seen = $now; protocol_version = 1 } | ConvertTo-Json -Compress
495
- try {
496
- if (-not (Test-Path -LiteralPath $crewSupervisorRoot -PathType Container)) { New-Item -ItemType Directory -Path $crewSupervisorRoot -Force | Out-Null }
497
- $temp = Join-Path $crewSupervisorRoot ("heartbeat.{0}.tmp" -f $PID)
498
- Write-Utf8NoBom -Path $temp -Content $record
499
- Move-Item -LiteralPath $temp -Destination $crewHeartbeatFile -Force
500
- } catch { /* heartbeat is best-effort */ }
501
- }
502
-
503
- function Get-SupervisorLaunchArguments {
504
- param([string] $ScriptPath = $PSCommandPath)
505
- if ([string]::IsNullOrWhiteSpace($ScriptPath) -or $ScriptPath.Contains('"')) {
506
- throw 'A valid launcher script path is required.'
507
- }
508
- return @(
509
- '-NoLogo',
510
- '-NoProfile',
511
- '-NonInteractive',
512
- '-ExecutionPolicy', 'Bypass',
513
- '-File', ('"{0}"' -f $ScriptPath),
514
- '-Mode', 'watch'
515
- )
516
- }
517
-
518
- function Get-SupervisorHeartbeatRecord {
519
- param([int] $MaxAgeSeconds = 30)
520
- try {
521
- if (-not (Test-Path -LiteralPath $crewHeartbeatFile -PathType Leaf)) { return $null }
522
- $record = Get-Content -LiteralPath $crewHeartbeatFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
523
- $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
524
- $lastSeen = [long] $record.last_seen
525
- $age = $now - $lastSeen
526
- if ($record.schema_version -ne 1 -or $record.protocol_version -ne 1 -or [int] $record.pid -le 0) { return $null }
527
- if ($age -lt -5000 -or $age -gt ([long] $MaxAgeSeconds * 1000)) { return $null }
528
- $process = Get-Process -Id ([int] $record.pid) -ErrorAction Stop
529
- $processStartProperty = $record.PSObject.Properties['process_started_at_utc_ticks']
530
- if ($null -ne $processStartProperty -and $processStartProperty.Value) {
531
- $expectedTicks = [long] $processStartProperty.Value
532
- if ($process.StartTime.ToUniversalTime().Ticks -ne $expectedTicks) { return $null }
533
- }
534
- $ownershipProperty = $record.PSObject.Properties['ownership_ready']
535
- $helperHashProperty = $record.PSObject.Properties['helper_hash']
536
- $instanceProperty = $record.PSObject.Properties['supervisor_instance_id']
537
- $identityReady = $null -ne $processStartProperty -and $processStartProperty.Value `
538
- -and $null -ne $helperHashProperty -and $helperHashProperty.Value -match '^[a-f0-9]{64}$' `
539
- -and $null -ne $instanceProperty -and -not [string]::IsNullOrWhiteSpace([string] $instanceProperty.Value)
540
- $state = if ($null -eq $ownershipProperty) { 'legacy-v1' } elseif ($ownershipProperty.Value -eq $true -and $identityReady) { 'ready' } else { 'starting' }
541
- return [pscustomobject]@{ State = $state; Record = $record }
542
- } catch {
543
- return $null
544
- }
545
- }
546
-
547
- function Get-FreshSupervisorHeartbeat {
548
- param([int] $MaxAgeSeconds = 30)
549
- $observed = Get-SupervisorHeartbeatRecord -MaxAgeSeconds $MaxAgeSeconds
550
- if ($observed -and $observed.State -eq 'ready') { return $observed.Record }
551
- return $null
552
- }
553
-
554
- function Ensure-CrewSupervisorRunning {
555
- param([int] $TimeoutSeconds = 90)
556
- $watcher = $null
557
- $observed = Get-SupervisorHeartbeatRecord
558
- if ($observed -and $observed.State -eq 'legacy-v1') {
559
- throw 'CREW_SUPERVISOR_UPGRADE_REQUIRED: a legacy watcher is active and must be handed off before interactive launch.'
560
- }
561
- if (-not $observed) {
562
- $arguments = @(Get-SupervisorLaunchArguments -ScriptPath $PSCommandPath)
563
- $watcher = Start-Process -FilePath 'powershell.exe' -ArgumentList $arguments -WindowStyle Hidden -PassThru
564
- Write-LaunchLog ('Started persistent Crew supervisor; PID={0}.' -f $watcher.Id)
565
- }
566
-
567
- $crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
568
- if (-not $crew) { throw 'Crew-owned 3210 service definition is missing.' }
569
- $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
570
- $nextProgress = (Get-Date).AddSeconds(5)
571
- $lastHealthError = 'not checked'
572
- do {
573
- $heartbeat = Get-FreshSupervisorHeartbeat
574
- $health = Get-HealthState $crew
575
- $lastHealthError = $health.Error
576
- if ($heartbeat -and $health.Ready) {
577
- Write-LaunchLog ('Persistent supervisor ready; PID={0}; 3210 runtime={1}.' -f $heartbeat.pid, $health.Version)
578
- return
579
- }
580
- if ((Get-Date) -ge $nextProgress) {
581
- Write-LaunchLog ('Waiting for Crew on 3210; {0:n0}s remaining. Health: {1}' -f [Math]::Max(0, ($deadline - (Get-Date)).TotalSeconds), $lastHealthError)
582
- $nextProgress = (Get-Date).AddSeconds(5)
583
- }
584
- Start-Sleep -Milliseconds 500
585
- } while ((Get-Date) -lt $deadline)
586
-
587
- throw ('Persistent supervisor did not make 3210 ready within {0}s. Last health error: {1}' -f $TimeoutSeconds, $lastHealthError)
588
- }
589
-
590
- function Read-RestartRequests {
591
- if (-not (Test-Path -LiteralPath $crewRequestsDir -PathType Container)) { return @() }
592
- $requests = @()
593
- Get-ChildItem -LiteralPath $crewRequestsDir -Filter '*.json' -File -ErrorAction SilentlyContinue | ForEach-Object {
594
- try {
595
- $parsed = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json -ErrorAction Stop
596
- if ($parsed.schema_version -eq 1 -and $parsed.operation -eq 'restart' -and $parsed.request_id) {
597
- $requests += $parsed
598
- }
599
- } catch { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }
600
- }
601
- return $requests
602
- }
603
-
604
- function Write-RestartResult {
605
- param([object] $Request, [string] $State, [object] $Detail = $null)
606
- try {
607
- if (-not (Test-Path -LiteralPath $crewResultsDir -PathType Container)) { New-Item -ItemType Directory -Path $crewResultsDir -Force | Out-Null }
608
- $result = @{
609
- schema_version = 1
610
- request_id = $Request.request_id
611
- operation = 'restart'
612
- state = $State
613
- runtime_id = $Request.runtime_id
614
- written_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
615
- detail = $Detail
616
- } | ConvertTo-Json -Depth 5
617
- $file = Join-Path $crewResultsDir ("{0}.json" -f $Request.request_id)
618
- $temp = Join-Path $crewResultsDir ("{0}.{1}.tmp" -f $Request.request_id, $PID)
619
- Write-Utf8NoBom -Path $temp -Content $result
620
- Move-Item -LiteralPath $temp -Destination $file -Force
621
- } catch { /* best effort */ }
622
- Remove-Item -LiteralPath (Join-Path $crewRequestsDir ("{0}.json" -f $Request.request_id)) -Force -ErrorAction SilentlyContinue
623
- }
624
-
625
- function Invoke-CrewRestartRequests {
626
- # Consume durable restart requests from the hub. Called by the watch loop.
627
- $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
628
- foreach ($request in (Read-RestartRequests)) {
629
- if (($request.expires_at -as [long]) -lt $now) {
630
- Write-RestartResult $request 'RESTART_REQUEST_EXPIRED'
631
- Write-LaunchLog ('Restart request {0} expired; not executed.' -f $request.request_id) 'WARN'
632
- continue
633
- }
634
- # Ownership authority: the request must name the SAME runtime identity we
635
- # own, and that identity must still be live on 3210.
636
- $crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
637
- if (-not $crew) { continue }
638
- $health = Get-HealthState $crew
639
- $liveIdentityOk = $false
640
- try {
641
- $response = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
642
- $liveRuntimeId = $response.extension.runtime.runtime_id
643
- $liveIdentityOk = ($response.ok -eq $true) -and ($liveRuntimeId -eq $request.runtime_id)
644
- } catch { $liveIdentityOk = $false }
645
- if (-not $liveIdentityOk) {
646
- Write-RestartResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
647
- Write-LaunchLog ('Restart request {0} rejected: live runtime_id does not match the request.' -f $request.request_id) 'WARN'
648
- continue
649
- }
650
- Write-LaunchLog ('Executing restart request {0} (reason: {1}).' -f $request.request_id, $request.reason)
651
- $previousRuntimeId = $request.runtime_id
652
- # Re-read the listener port right before the kill so the PID identity is
653
- # fresh: Stop-OwnedListener requires a PID that equals the tracked
654
- # listener and belongs to the current owned process tree.
655
- $port = Get-PortState $crew.Port
656
- if ($port.State -ne 'occupied' -or -not $port.Pid) {
657
- Write-RestartResult $request 'SUPERVISOR_STOP_FAILED'
658
- Write-LaunchLog ('Restart request {0} failed: no listener on port {1}.' -f $request.request_id, $crew.Port) 'ERROR'
659
- continue
660
- }
661
- $stopped = Stop-OwnedListener -Service $crew -ListenerPid ([int] $port.Pid)
662
- if (-not $stopped) {
663
- Write-RestartResult $request 'SUPERVISOR_STOP_FAILED'
664
- Write-LaunchLog ('Restart request {0} failed to stop the owned runtime.' -f $request.request_id) 'ERROR'
665
- continue
666
- }
667
- Start-CrewService $crew
668
- Wait-CrewServices
669
- # Verify: runtime_id must have changed and the cohort must still match.
670
- $newRuntimeId = $null
671
- try {
672
- $response = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
673
- $newRuntimeId = $response.extension.runtime.runtime_id
674
- } catch { $newRuntimeId = $null }
675
- if ($newRuntimeId -and $newRuntimeId -ne $previousRuntimeId) {
676
- Write-RestartResult $request 'VERIFIED' @{ previous_runtime_id = $previousRuntimeId; runtime_id = $newRuntimeId }
677
- Write-LaunchLog ('Restart request {0} verified: runtime_id {1} -> {2}.' -f $request.request_id, $previousRuntimeId, $newRuntimeId)
678
- } else {
679
- Write-RestartResult $request 'VERIFY_FAILED' @{ previous_runtime_id = $previousRuntimeId; runtime_id = $newRuntimeId }
680
- Write-LaunchLog ('Restart request {0} verification failed: runtime_id did not change.' -f $request.request_id) 'ERROR'
681
- }
682
- }
683
- }
684
-
685
- $crewMaintenanceRequestsDir = Join-Path $crewSupervisorRoot 'maintenance-requests'
686
- $crewMaintenanceResultsDir = Join-Path $crewSupervisorRoot 'maintenance-results'
687
- $crewMaintenanceSessionFile = Join-Path $crewSupervisorRoot 'maintenance-session.json'
688
-
689
- function Test-MaintenanceSessionActive {
690
- # Fence for ordinary Crew auto-start. Returns $true when a STOPPED session
691
- # holds the launch right OR when the session file is present but MALFORMED
692
- # (fail closed: a corrupt session must not let supervision auto-start
693
- # 3210, because the stopped window may belong to a live npx swap).
694
- # ABSENT means no fence: ordinary supervision proceeds.
695
- $session = Read-MaintenanceSession
696
- if ($session -eq 'ABSENT') { return $false }
697
- return $true
698
- }
699
-
700
- function Set-MaintenanceSession {
701
- param([object] $Request)
702
- # Strong-failure semantics: the STOPPED result may only be published after
703
- # the session is durably written AND read back with exact identity. Any
704
- # failure returns $false and the caller must NOT claim the stopped window.
705
- try {
706
- $session = @{
707
- schema_version = 1
708
- state = 'STOPPED'
709
- lease = $Request.lease
710
- runtime_id = $Request.runtime_id
711
- stopped_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
712
- request_id = $Request.request_id
713
- } | ConvertTo-Json -Compress
714
- $temp = Join-Path $crewSupervisorRoot ("maintenance-session.{0}.tmp" -f $PID)
715
- Write-Utf8NoBom -Path $temp -Content $session
716
- Move-Item -LiteralPath $temp -Destination $crewMaintenanceSessionFile -Force
717
- $back = Read-MaintenanceSession
718
- if ($back -is [string]) { return $false }
719
- return ($back.schema_version -eq 1 -and $back.state -eq 'STOPPED' -and $back.lease -eq $Request.lease -and $back.runtime_id -eq $Request.runtime_id -and $back.request_id -eq $Request.request_id)
720
- } catch { return $false }
721
- }
722
-
723
- function Clear-MaintenanceSession {
724
- # Verifiable one-shot consumption: returns $true only when the session
725
- # file is provably ABSENT afterwards (otherwise the lease is NOT consumed
726
- # and replay stays impossible because start re-validates the session).
727
- try {
728
- Remove-Item -LiteralPath $crewMaintenanceSessionFile -Force -ErrorAction Stop
729
- } catch {
730
- if (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf) { return $false }
731
- return $true
732
- }
733
- return (-not (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf))
734
- }
735
-
736
- function Read-MaintenanceSession {
737
- # Tri-state: 'ABSENT' (no file), a session object when valid, or the
738
- # string 'MALFORMED' (present but unreadable/invalid). MALFORMED fails
739
- # closed for Crew auto-start (see Test-MaintenanceSessionActive).
740
- try {
741
- if (-not (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf)) { return 'ABSENT' }
742
- $parsed = Get-Content -LiteralPath $crewMaintenanceSessionFile -Raw | ConvertFrom-Json -ErrorAction Stop
743
- if ($parsed.schema_version -ne 1 -or $parsed.state -ne 'STOPPED' -or -not $parsed.lease -or -not $parsed.runtime_id -or -not $parsed.request_id) {
744
- return 'MALFORMED'
745
- }
746
- return $parsed
747
- } catch { return 'MALFORMED' }
748
- }
749
-
750
- function Write-MaintenanceResult {
751
- param([object] $Request, [string] $State, [object] $Detail = $null)
752
- try {
753
- if (-not (Test-Path -LiteralPath $crewMaintenanceResultsDir -PathType Container)) { New-Item -ItemType Directory -Path $crewMaintenanceResultsDir -Force | Out-Null }
754
- $result = @{
755
- schema_version = 1
756
- request_id = $Request.request_id
757
- operation = $Request.operation
758
- state = $State
759
- lease = $Request.lease
760
- runtime_id = $Request.runtime_id
761
- written_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
762
- detail = $Detail
763
- } | ConvertTo-Json -Depth 5
764
- $file = Join-Path $crewMaintenanceResultsDir ("{0}.json" -f $Request.request_id)
765
- $temp = Join-Path $crewMaintenanceResultsDir ("{0}.{1}.tmp" -f $Request.request_id, $PID)
766
- Write-Utf8NoBom -Path $temp -Content $result
767
- Move-Item -LiteralPath $temp -Destination $file -Force
768
- } catch { /* best effort */ }
769
- Remove-Item -LiteralPath (Join-Path $crewMaintenanceRequestsDir ("{0}.json" -f $Request.request_id)) -Force -ErrorAction SilentlyContinue
770
- }
771
-
772
- function Invoke-CrewMaintenanceRequests {
773
- # Consume maintenance transactions from npx lifecycle (cohort migration).
774
- # The npx process owns the runtime TREE swap; this watcher owns the
775
- # PROCESS stop/start around it. Two phases, both verified:
776
- # maintenance-stop -> stop owned 3210, write STOPPED (+lease)
777
- # maintenance-start -> start 3210, verify new identity + cohort, VERIFIED
778
- $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
779
- if (-not (Test-Path -LiteralPath $crewMaintenanceRequestsDir -PathType Container)) { return }
780
- $files = Get-ChildItem -LiteralPath $crewMaintenanceRequestsDir -Filter '*.json' -File -ErrorAction SilentlyContinue
781
- foreach ($file in $files) {
782
- try {
783
- $request = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -ErrorAction Stop
784
- } catch {
785
- Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue
786
- continue
787
- }
788
- if ($request.schema_version -ne 1 -or -not $request.request_id -or -not $request.operation) {
789
- Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue
790
- continue
791
- }
792
- if (($request.expires_at -as [long]) -lt $now) {
793
- Write-MaintenanceResult $request 'MAINTENANCE_EXPIRED'
794
- Write-LaunchLog ('Maintenance request {0} ({1}) expired; not executed.' -f $request.request_id, $request.operation) 'WARN'
795
- continue
796
- }
797
- $crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
798
- if (-not $crew) { continue }
799
- if ($request.operation -eq 'maintenance-stop') {
800
- # Authority: the request must name the SAME live runtime identity we own.
801
- $liveRuntimeId = $null
802
- try {
803
- $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
804
- $runtime = $resp.extension.runtime
805
- $canonical = $resp.ok -eq $true `
806
- -and $runtime.service -eq 'dsh-crew-hub' `
807
- -and $runtime.execution_plane -eq 'hub-3210' `
808
- -and $runtime.profile -eq 'dsh-crew' `
809
- -and ($runtime.listen_port -as [int]) -eq 3210 `
810
- -and ($runtime.protocol_version -as [int]) -eq 1
811
- if ($canonical) { $liveRuntimeId = $runtime.runtime_id }
812
- } catch { $liveRuntimeId = $null }
813
- if (-not $liveRuntimeId -or $liveRuntimeId -ne $request.runtime_id) {
814
- Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
815
- Write-LaunchLog ('Maintenance-stop {0} rejected: live runtime_id mismatch.' -f $request.request_id) 'WARN'
816
- continue
817
- }
818
- $port = Get-PortState $crew.Port
819
- if ($port.State -ne 'occupied' -or -not $port.Pid) {
820
- Write-MaintenanceResult $request 'SUPERVISOR_STOP_FAILED'
821
- continue
822
- }
823
- $stopped = Stop-OwnedListener -Service $crew -ListenerPid ([int] $port.Pid)
824
- if ($stopped) {
825
- # The stopped window now belongs to the npx lifecycle: persist the
826
- # STOPPED session (lease + proven runtime_id) so ordinary
827
- # supervision will NOT auto-start 3210 until the matching start.
828
- # STRONG semantics: STOPPED is published ONLY after the session is
829
- # durably written AND read back with exact identity. A session write
830
- # failure must never tell npx it owns a stopped window it cannot
831
- # later prove (that race auto-restarts 3210 mid tree-swap).
832
- $sessionDurable = Set-MaintenanceSession $request
833
- if ($sessionDurable) {
834
- Write-MaintenanceResult $request 'STOPPED' @{ lease = $request.lease; stopped_runtime_id = $request.runtime_id }
835
- Write-LaunchLog ('Maintenance-stop {0} executed; lease issued.' -f $request.request_id)
836
- } else {
837
- Write-MaintenanceResult $request 'SUPERVISOR_SESSION_PERSIST_FAILED'
838
- Write-LaunchLog ('Maintenance-stop {0} stopped the process but the STOPPED session could not be persisted; NOT publishing STOPPED.' -f $request.request_id) 'ERROR'
839
- }
840
- } else {
841
- Write-MaintenanceResult $request 'SUPERVISOR_STOP_FAILED'
842
- }
843
- } elseif ($request.operation -eq 'maintenance-start') {
844
- # Pair with the matching STOPPED session: the start request must carry
845
- # the SAME lease and runtime_id the stop proved. A missing session,
846
- # mismatched lease/identity, or a still-listening port all fail closed
847
- # WITHOUT starting anything. The session is consumed on VERIFIED so
848
- # the lease is one-shot (no replay).
849
- $lease = $request.lease
850
- $expectedCrew = $null
851
- $expectedDsh = $null
852
- if ($request.extra) {
853
- $expectedCrew = $request.extra.expected_crew_version
854
- $expectedDsh = $request.extra.expected_dsh_version
855
- }
856
- $session = Read-MaintenanceSession
857
- if ($session -is [string] -or $session.lease -ne $lease -or $session.runtime_id -ne $request.runtime_id) {
858
- Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
859
- Write-LaunchLog ('Maintenance-start {0} rejected: no matching STOPPED session (lease/identity mismatch).' -f $request.request_id) 'WARN'
860
- continue
861
- }
862
- $livePort = Get-PortState $crew.Port
863
- if ($livePort.State -ne 'free') {
864
- # occupied AND unknown both fail closed: the stopped window is not
865
- # provably clean, so no start authority.
866
- Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
867
- Write-LaunchLog ('Maintenance-start {0} rejected: port {1} is not provably free (state={2}).' -f $request.request_id, $crew.Port, $livePort.State) 'WARN'
868
- continue
869
- }
870
- Start-CrewService $crew
871
- Wait-CrewServices
872
- $newRuntime = $null
873
- try {
874
- $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 5
875
- if ($resp.ok -eq $true) { $newRuntime = $resp.extension.runtime }
876
- } catch { $newRuntime = $null }
877
- $ok = $newRuntime -and $newRuntime.runtime_id `
878
- -and $newRuntime.service -eq 'dsh-crew-hub' `
879
- -and $newRuntime.execution_plane -eq 'hub-3210' `
880
- -and $newRuntime.profile -eq 'dsh-crew' `
881
- -and ($newRuntime.listen_port -as [int]) -eq 3210 `
882
- -and ($newRuntime.protocol_version -as [int]) -eq 1 `
883
- -and ($expectedCrew -eq $null -or $newRuntime.runtime_version -eq $expectedCrew) `
884
- -and ($expectedDsh -eq $null -or $newRuntime.dsh_version -eq $expectedDsh)
885
- if ($ok) {
886
- # Verifiable one-shot consumption: only a provably-cleared session
887
- # completes the transaction; otherwise the lease stays live and any
888
- # replay is still fenced (no VERIFIED without consumption proof).
889
- $consumed = Clear-MaintenanceSession
890
- if ($consumed) {
891
- Write-MaintenanceResult $request 'VERIFIED' @{ lease = $lease; runtime_id = $newRuntime.runtime_id; runtime_version = $newRuntime.runtime_version; dsh_version = $newRuntime.dsh_version }
892
- Write-LaunchLog ('Maintenance-start {0} verified: Crew {1} + DSH {2}.' -f $request.request_id, $newRuntime.runtime_version, $newRuntime.dsh_version)
893
- } else {
894
- Write-MaintenanceResult $request 'VERIFY_FAILED' @{ lease = $lease; runtime_id = $newRuntime.runtime_id }
895
- Write-LaunchLog ('Maintenance-start {0} identity verified but session consumption unproven; NOT marking VERIFIED.' -f $request.request_id) 'ERROR'
896
- }
897
- } else {
898
- $failedRuntimeId = $null
899
- if ($newRuntime) { $failedRuntimeId = $newRuntime.runtime_id }
900
- Write-MaintenanceResult $request 'VERIFY_FAILED' @{ lease = $lease; runtime_id = $failedRuntimeId }
901
- Write-LaunchLog ('Maintenance-start {0} verification failed.' -f $request.request_id) 'ERROR'
902
- }
903
- } else {
904
- # Unknown maintenance op: remove and report.
905
- Write-MaintenanceResult $request 'MAINTENANCE_UNKNOWN_OP'
906
- }
907
- }
908
- }
909
-
910
-
911
- function Get-PortState {
912
- param([int] $Port)
913
- try {
914
- $listeners = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners()
915
- $occupied = @($listeners | Where-Object Port -eq $Port).Count -gt 0
916
- if (-not $occupied) {
917
- return [pscustomobject]@{ State = 'free'; Error = $null; Pid = $null }
918
- }
919
-
920
- $ownerPid = $null
921
- try {
922
- $ownerPid = (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop | Select-Object -First 1).OwningProcess
923
- } catch { }
924
- return [pscustomobject]@{ State = 'occupied'; Error = $null; Pid = $ownerPid }
925
- } catch {
926
- return [pscustomobject]@{ State = 'unknown'; Error = ('Listener enumeration failed: {0}' -f $_.Exception.Message); Pid = $null }
927
- }
928
- }
929
-
930
- function Test-TrackedProcessIdentity {
931
- param([int] $ProcessId, [long] $ExpectedStartTicks, [object[]] $ProcessTable)
932
- if ($ProcessId -lt 1 -or $ExpectedStartTicks -lt 1) { return $false }
933
- $record = @($ProcessTable | Where-Object { [int] $_.ProcessId -eq $ProcessId } | Select-Object -First 1)
934
- if ($record.Count -eq 0) { return $false }
935
- $startTicks = $null
936
- if ($record[0].PSObject.Properties.Name -contains 'StartTicks') {
937
- $startTicks = [long] $record[0].StartTicks
938
- } else {
939
- try { $startTicks = (Get-Process -Id $ProcessId -ErrorAction Stop).StartTime.ToUniversalTime().Ticks } catch { return $false }
940
- }
941
- return $startTicks -eq $ExpectedStartTicks
942
- }
943
-
944
- function Get-TrackedProcessTree {
945
- param([pscustomobject] $Service, [object[]] $ProcessTable = $null)
946
- $processes = if ($null -ne $ProcessTable) { @($ProcessTable) } else { @(Get-CimInstance Win32_Process -ErrorAction Stop) }
947
- $owned = [System.Collections.Generic.HashSet[int]]::new()
948
- $rootMatches = Test-TrackedProcessIdentity -ProcessId $Service.RootPid -ExpectedStartTicks $Service.RootStartedAtUtcTicks -ProcessTable $processes
949
- $hasTrackedListener = $Service.ListenerPid -and $Service.ListenerStartedAtUtcTicks
950
- if ($hasTrackedListener) {
951
- $listenerMatches = Test-TrackedProcessIdentity -ProcessId $Service.ListenerPid -ExpectedStartTicks $Service.ListenerStartedAtUtcTicks -ProcessTable $processes
952
- if (-not $listenerMatches) { return @() }
953
- [void] $owned.Add([int] $Service.ListenerPid)
954
- } elseif (-not $rootMatches) {
955
- return @()
956
- }
957
- if ($rootMatches) { [void] $owned.Add([int] $Service.RootPid) }
958
- do {
959
- $added = $false
960
- foreach ($candidate in $processes) {
961
- $candidateId = [int] $candidate.ProcessId
962
- $parentId = [int] $candidate.ParentProcessId
963
- if ($owned.Contains($parentId) -and $owned.Add($candidateId)) { $added = $true }
964
- }
965
- } while ($added)
966
- return @($owned | ForEach-Object { [int] $_ })
967
- }
968
-
969
- function Set-TrackedListenerIdentity {
970
- param([pscustomobject] $Service)
971
- $port = Get-PortState $Service.Port
972
- if ($port.State -ne 'occupied' -or -not $port.Pid) { return $false }
973
- $tree = @(Get-TrackedProcessTree -Service $Service)
974
- if ($port.Pid -notin $tree) { return $false }
975
- try {
976
- $listener = Get-Process -Id $port.Pid -ErrorAction Stop
977
- $Service.ListenerPid = [int] $port.Pid
978
- $Service.ListenerStartedAtUtcTicks = $listener.StartTime.ToUniversalTime().Ticks
979
- return $true
980
- } catch {
981
- return $false
982
- }
983
- }
984
-
985
- function Test-CrewServiceOwnership {
986
- param(
987
- [pscustomObject] $Service,
988
- [pscustomObject] $PortState = $null,
989
- [object[]] $ProcessTable = $null
990
- )
991
- if (-not $Service.ListenerPid -or -not $Service.ListenerStartedAtUtcTicks) { return $false }
992
- $port = if ($null -ne $PortState) { $PortState } else { Get-PortState $Service.Port }
993
- if ($port.State -ne 'occupied' -or -not $port.Pid -or [int] $port.Pid -ne [int] $Service.ListenerPid) { return $false }
994
- try {
995
- $tree = if ($null -ne $ProcessTable) {
996
- @(Get-TrackedProcessTree -Service $Service -ProcessTable $ProcessTable)
997
- } else {
998
- @(Get-TrackedProcessTree -Service $Service)
999
- }
1000
- return ([int] $port.Pid -in $tree)
1001
- } catch {
1002
- return $false
1003
- }
1004
- }
1005
-
1006
- function Stop-OwnedListener {
1007
- param([pscustomobject] $Service, [int] $ListenerPid)
1008
- if ($Mode -ne 'watch' -or -not $Service.ListenerPid -or $ListenerPid -ne $Service.ListenerPid) {
1009
- return $false
1010
- }
1011
- try {
1012
- $ownedProcessIds = @(Get-TrackedProcessTree -Service $Service)
1013
- if ($ListenerPid -notin $ownedProcessIds) { return $false }
1014
- Write-LaunchLog ('Supervisor confirmed owned listener PID={0} under tracked root PID={1}; stopping that process tree after {2} consecutive failed health checks.' -f $ListenerPid, $Service.RootPid, $Service.ConsecutiveFailures) 'WARN'
1015
- foreach ($processId in $ownedProcessIds) {
1016
- Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
1017
- }
1018
- $deadline = (Get-Date).AddSeconds(5)
1019
- do {
1020
- $port = Get-PortState $Service.Port
1021
- if ($port.State -eq 'free') { return $true }
1022
- Start-Sleep -Milliseconds 250
1023
- } while ((Get-Date) -lt $deadline)
1024
- } catch {
1025
- Write-LaunchLog ('Supervisor could not stop its owned listener safely: {0}' -f $_.Exception.Message) 'WARN'
1026
- }
1027
- return $false
1028
- }
1029
-
1030
- function Assert-HistoryStartAllowed {
1031
- param([string] $StatePath = (Join-Path $crewHome '..\history\active.json'))
1032
- if (-not (Test-Path -LiteralPath $StatePath)) { return }
1033
- try {
1034
- $state = Get-Content -LiteralPath $StatePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
1035
- if ($state.schemaVersion -eq 1 -and $state.phase -in @('STARTING', 'VERIFYING', 'DONE', 'FAILED', 'ROLLED_BACK')) { return }
1036
- } catch { }
1037
- throw 'HISTORY_RECOVERY_REQUIRED: Crew history maintenance is unfinished. Run dsh-crew history recover. Official 3080 is unchanged.'
1038
- }
1039
-
1040
- function Start-CrewService {
1041
- param([pscustomobject] $Service)
1042
- Assert-HistoryStartAllowed
1043
- $serviceRunStamp = (Get-Date).ToString('yyyyMMdd-HHmmssfff')
1044
- $stdout = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.out.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
1045
- $stderr = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.err.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
1046
- $previousHome = $env:DSH_HOME
1047
- try {
1048
- $env:DSH_HOME = $Service.Home
1049
- $arguments = @('--profile', $Service.Profile, '--host', '127.0.0.1', '--port', [string] $Service.Port, '--no-open')
1050
- $launchArguments = if ($dshCliIsNodeEntry) { @($dshCli) + $arguments } else { $arguments }
1051
- $process = Start-Process -FilePath $dshCommand -ArgumentList $launchArguments -WindowStyle Hidden -PassThru `
1052
- -RedirectStandardOutput $stdout -RedirectStandardError $stderr
1053
- $Service.Process = $process
1054
- $Service.RootPid = $process.Id
1055
- $Service.RootStartedAtUtcTicks = $process.StartTime.ToUniversalTime().Ticks
1056
- $Service.ListenerPid = $null
1057
- $Service.ListenerStartedAtUtcTicks = $null
1058
- $Service.ConsecutiveFailures = 0
1059
- $Service.State = 'starting'
1060
- Write-LaunchLog ('Started {0} on port {1}; PID={2}; stdout={3}; stderr={4}' -f $Service.Profile, $Service.Port, $process.Id, $stdout, $stderr)
1061
- } finally {
1062
- $env:DSH_HOME = $previousHome
1063
- }
1064
- }
1065
-
1066
- function Wait-CrewServices {
1067
- $deadline = (Get-Date).AddSeconds(90)
1068
- while (@($services | Where-Object State -eq 'starting').Count -gt 0 -and (Get-Date) -lt $deadline) {
1069
- foreach ($service in ($services | Where-Object State -eq 'starting')) {
1070
- $health = Get-HealthState $service
1071
- $service.LastError = $health.Error
1072
- if ($health.Ready) {
1073
- if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
1074
- throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
1075
- }
1076
- if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
1077
- throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
1078
- }
1079
- $service.State = 'ready'
1080
- $service.ConsecutiveFailures = 0
1081
- Write-LaunchLog ('{0} ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
1082
- } elseif ($service.Process -and $service.Process.HasExited) {
1083
- throw ('{0} exited before becoming ready; PID={1}; exit={2}; last health error: {3}' -f $service.Name, $service.Process.Id, $service.Process.ExitCode, $health.Error)
1084
- }
1085
- }
1086
- if (@($services | Where-Object State -eq 'starting').Count -gt 0) { Start-Sleep -Milliseconds 500 }
1087
- }
1088
-
1089
- $notReady = @($services | Where-Object {
1090
- $_.State -ne 'ready'
1091
- })
1092
- if ($notReady.Count -gt 0) {
1093
- $details = ($notReady | ForEach-Object { '{0}:{1} ({2})' -f $_.Profile, $_.Port, $_.LastError }) -join '; '
1094
- throw "Startup health deadline exceeded: $details"
1095
- }
1096
- }
1097
-
1098
- # Explicit diagnostic helper; not called by normal startup or supervision.
1099
- function Write-LegacyBridgeDiagnostic {
1100
- if (Test-LegacyBridgeAvailable) {
1101
- Write-LaunchLog 'Diagnostic: legacy 3080 bridge still answers ping; Crew launcher owns 3210 directly, bridge ignored.'
1102
- }
1103
- }
1104
-
1105
- function Ensure-CrewServices {
1106
- param([switch] $QuietHealthy)
1107
-
1108
- foreach ($service in $services) {
1109
- # Maintenance fence: while a STOPPED maintenance session is active for
1110
- # the Crew backend, ordinary supervision must NOT auto-start 3210 —
1111
- # the stopped window belongs to the npx lifecycle's tree swap. Only a
1112
- # matching maintenance-start owns the launch right.
1113
- if ($service.CrewOwned -and (Test-MaintenanceSessionActive)) {
1114
- $service.State = 'maintenance'
1115
- $service.LastError = $null
1116
- continue
1117
- }
1118
- $health = Get-HealthState $service
1119
- if ($health.Ready) {
1120
- $wasReady = $service.State -eq 'ready'
1121
- if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
1122
- throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
1123
- }
1124
- if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
1125
- throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
1126
- }
1127
- $service.State = 'ready'
1128
- $service.ConsecutiveFailures = 0
1129
- $service.LastError = $null
1130
- if (-not $QuietHealthy -or -not $wasReady) {
1131
- Write-LaunchLog ('{0} already ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
1132
- }
1133
- continue
1134
- }
1135
-
1136
- # The Crew launcher owns 3210 directly and always boots it below.
1137
- # Legacy bridge presence is diagnosed only after 3210 is ready (see
1138
- # Wait-CrewServices tail), never on the boot path.
1139
-
1140
- $service.LastError = $health.Error
1141
- $service.ConsecutiveFailures += 1
1142
- if ($service.Process -and $service.Process.HasExited) {
1143
- Write-LaunchLog ('{0} process exited after startup; PID={1}; exit={2}' -f $service.Name, $service.Process.Id, $service.Process.ExitCode) 'WARN'
1144
- $service.Process = $null
1145
- }
1146
- $service.State = 'pending'
1147
-
1148
- $port = Get-PortState $service.Port
1149
- if ($port.State -eq 'occupied') {
1150
- if ($service.ConsecutiveFailures -lt 3) {
1151
- throw ('Health check {0}/3 failed for {1}; owned process remains untouched until failure is confirmed. Health error: {2}' -f $service.ConsecutiveFailures, $service.Name, $health.Error)
1152
- }
1153
- if (Stop-OwnedListener -Service $service -ListenerPid $port.Pid) {
1154
- $service.Process = $null
1155
- $service.RootPid = $null
1156
- $service.RootStartedAtUtcTicks = $null
1157
- $service.ListenerPid = $null
1158
- $service.ListenerStartedAtUtcTicks = $null
1159
- $service.ConsecutiveFailures = 0
1160
- $port = Get-PortState $service.Port
1161
- }
1162
- }
1163
- if ($port.State -eq 'occupied') {
1164
- $owner = if ($port.Pid) { "; listener PID=$($port.Pid)" } else { '' }
1165
- throw ('Port {0} is occupied, but {1} failed its health contract{2}. Health error: {3}' -f $service.Port, $service.Name, $owner, $health.Error)
1166
- }
1167
- if ($port.State -ne 'free') {
1168
- throw ('Could not determine whether port {0} is available: {1}' -f $service.Port, $port.Error)
1169
- }
1170
- if ($Mode -eq 'watch') {
1171
- Write-LaunchLog ('Supervisor detected {0} unavailable on {1}; restarting it.' -f $service.Name, $service.Port) 'WARN'
1172
- }
1173
- Start-CrewService $service
1174
- }
1175
-
1176
- Wait-CrewServices
1177
- }
1178
-
1179
- function Start-ServiceSupervisor {
1180
- $mutex = [System.Threading.Mutex]::new($false, 'Local\DSHCrewServiceSupervisor')
1181
- $ownsMutex = $false
1182
- try {
1183
- try {
1184
- $ownsMutex = $mutex.WaitOne(0)
1185
- } catch [System.Threading.AbandonedMutexException] {
1186
- $ownsMutex = $true
1187
- }
1188
- if (-not $ownsMutex) {
1189
- Write-LaunchLog 'Supervisor already active; duplicate watcher exiting.'
1190
- return
1191
- }
1192
-
1193
- # Publish process identity before any 3210 startup/health wait. Handoff
1194
- # callers can now distinguish a legitimate cold-starting target watcher
1195
- # from the stale heartbeat left by the watcher it replaced.
1196
- Write-SupervisorHeartbeat -OwnershipReady $false
1197
- Write-LaunchLog 'Supervisor active; monitoring Crew-owned 3210 every 10 seconds.'
1198
- $lastRecoveryError = $null
1199
- $updateLockFile = Join-Path $crewHome '..\app\update-in-progress.lock'
1200
- $staleLockNotified = $false
1201
- while ($true) {
1202
- try {
1203
- # An installer-held update lock suspends recovery restarts: the tree
1204
- # under Crew-owned state may be mid-migration, and a restart now
1205
- # would boot a half-installed runtime/profile. A lock whose owner
1206
- # process is dead is stale: log once and resume supervision instead
1207
- # of staying observe-only forever.
1208
- $updateHeld = $false
1209
- if (Test-Path -LiteralPath $updateLockFile -PathType Leaf) {
1210
- $lockAlive = $false
1211
- try {
1212
- $lockRecord = Get-Content -LiteralPath $updateLockFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
1213
- $lockPid = [int] $lockRecord.pid
1214
- if ($lockPid -gt 0) {
1215
- Get-Process -Id $lockPid -ErrorAction Stop | Out-Null
1216
- $lockAlive = $true
1217
- }
1218
- } catch { $lockAlive = $false }
1219
- if ($lockAlive) {
1220
- $updateHeld = $true
1221
- $staleLockNotified = $false
1222
- } elseif (-not $staleLockNotified) {
1223
- Write-LaunchLog 'Stale update lock detected (owner process dead); resuming supervision. Re-run install/update to reconcile.' 'WARN'
1224
- $staleLockNotified = $true
1225
- }
1226
- } else {
1227
- $staleLockNotified = $false
1228
- }
1229
- # Maintenance stop/start is the updater's owned process handoff and
1230
- # must remain live while the update lock fences ordinary recovery.
1231
- Invoke-CrewMaintenanceRequests
1232
- if ($updateHeld) {
1233
- Write-LaunchLog 'Update in progress; maintenance only, ordinary recovery suspended.'
1234
- $lastRecoveryError = $null
1235
- } else {
1236
- # Control protocol: publish this watcher's heartbeat and consume
1237
- # any durable restart requests the hub wrote (3210 never spawns
1238
- # itself). Maintenance was consumed above; now run the ordinary
1239
- # health pass.
1240
- Invoke-CrewRestartRequests
1241
- Ensure-CrewServices -QuietHealthy
1242
- if ($lastRecoveryError) {
1243
- Write-LaunchLog 'Supervisor recovery succeeded; Crew-owned 3210 is healthy.'
1244
- $lastRecoveryError = $null
1245
- }
1246
- }
1247
- $ownedServices = @($services | Where-Object { $_.CrewOwned -and (Test-CrewServiceOwnership -Service $_) })
1248
- $expectedServices = @($services | Where-Object { $_.CrewOwned })
1249
- Write-SupervisorHeartbeat -OwnershipReady ($expectedServices.Count -gt 0 -and $ownedServices.Count -eq $expectedServices.Count)
1250
- } catch {
1251
- $recoveryError = $_.Exception.Message
1252
- Write-SupervisorHeartbeat -OwnershipReady $false
1253
- if ($recoveryError -ne $lastRecoveryError) {
1254
- Write-LaunchLog ('Supervisor recovery failed; will retry: {0}' -f $recoveryError) 'WARN'
1255
- $lastRecoveryError = $recoveryError
1256
- }
1257
- }
1258
- Start-Sleep -Seconds 10
1259
- }
1260
- } finally {
1261
- if ($ownsMutex) { $mutex.ReleaseMutex() }
1262
- $mutex.Dispose()
1263
- }
1264
- }
1265
-
1266
- if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { return }
1267
-
1268
- try {
1269
- New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
1270
- Write-LaunchLog ('Launcher started; mode={0}; user={1}' -f $Mode, $env:USERNAME)
1271
- if (-not (Test-Path -LiteralPath $dshCli -PathType Leaf)) {
1272
- throw "DSH CLI was not found at $dshCli. Configure DSH_CREW_DSH_CLI to a Crew-owned official CLI entry, or run: npm install -g @ran-sh/dsh-crew@latest; dsh-crew update"
1273
- }
1274
- Test-CrewDshCliPreflight
1275
- if (-not (Test-Path -LiteralPath (Join-Path $crewHome 'profiles\dsh-crew\package.json') -PathType Leaf)) {
1276
- throw "The isolated dsh-crew profile is missing under $crewHome. Run: dsh-crew update"
1277
- }
1278
- if ($Mode -eq 'open') { Open-OfficialFrontend }
1279
- # Background/watch modes remain independent of the official frontend.
1280
-
1281
- if ($Mode -eq 'watch') {
1282
- Start-ServiceSupervisor
1283
- Write-LaunchLog 'Supervisor stopped.' 'WARN'
1284
- exit 0
1285
- }
1286
-
1287
- Ensure-CrewSupervisorRunning
1288
-
1289
- if ($Mode -eq 'open') {
1290
- Write-LaunchLog 'Official frontend is on 3080; Crew is running silently on 3210.'
1291
- }
1292
- Write-LaunchLog ('Launcher completed successfully in {0:n1}s.' -f ((Get-Date) - $startedAt).TotalSeconds)
1293
- exit 0
1294
- } catch {
1295
- Write-LaunchLog $_.Exception.Message 'ERROR'
1296
- if ($Mode -eq 'open') {
1297
- Write-Host ''
1298
- Write-Host "Diagnostic log: $launcherLog" -ForegroundColor Yellow
1299
- Write-Host 'Startup failed. Review the diagnostic log and the service stdout/stderr paths recorded above.' -ForegroundColor Yellow
1300
- }
1301
- exit 1
1302
- }
1
+ # DSH Crew managed Windows launcher
2
+ [CmdletBinding()]
3
+ param(
4
+ [ValidateSet('background', 'open', 'watch')]
5
+ [string] $Mode = 'open'
6
+ )
7
+
8
+ Set-StrictMode -Version Latest
9
+ $ErrorActionPreference = 'Stop'
10
+
11
+ $crewHome = Join-Path $env:USERPROFILE '.config\dsh-crew\harness'
12
+ $configuredDshCli = [string] $env:DSH_CREW_DSH_CLI
13
+ if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { $configuredDshCli = '' }
14
+ if (-not $configuredDshCli.Trim() -and $env:DSH_CREW_LAUNCHER_TEST_IMPORT -ne '1') {
15
+ # Crew-managed npm runtime: the normal install path, written by dsh-crew
16
+ # update and carrying whatever cohort that release pinned. An explicit
17
+ # DSH_CREW_DSH_CLI always wins so an operator can still pin another entry.
18
+ $runtimeEntry = Join-Path $crewHome 'runtime\node_modules\@deepseek-ai\dsh\lib\bin.js'
19
+ if (Test-Path -LiteralPath $runtimeEntry -PathType Leaf) {
20
+ $configuredDshCli = $runtimeEntry
21
+ }
22
+ }
23
+ if (-not $configuredDshCli.Trim() -and $env:DSH_CREW_LAUNCHER_TEST_IMPORT -ne '1') {
24
+ # Crew-managed source cohort: a runtime-source-<label>.json sidecar names the
25
+ # CLI entry. The sidecar's recorded version must equal the checkout's own
26
+ # manifest, so a stale sidecar can never pin a half-updated tree.
27
+ foreach ($sidecar in @(Get-ChildItem -LiteralPath $crewHome -Filter 'runtime-source-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
28
+ try {
29
+ $metadata = Get-Content -LiteralPath $sidecar.FullName -Raw | ConvertFrom-Json
30
+ if ($metadata.managed_by -ne 'dsh-crew') { continue }
31
+ $candidate = [string] $metadata.cli_entry
32
+ if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
33
+ $sourceRoot = [IO.Path]::GetFullPath((Join-Path $crewHome ([IO.Path]::GetFileNameWithoutExtension($sidecar.Name))))
34
+ $candidateFull = [IO.Path]::GetFullPath($candidate)
35
+ $sourcePrefix = $sourceRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
36
+ $sourceManifest = Join-Path $sourceRoot 'apps\cli\package.json'
37
+ $sourceVersion = if (Test-Path -LiteralPath $sourceManifest -PathType Leaf) {
38
+ [string] (Get-Content -LiteralPath $sourceManifest -Raw | ConvertFrom-Json).version
39
+ } else { $null }
40
+ $candidateValid = $sourceVersion -and $sourceVersion -eq [string] $metadata.version `
41
+ -and $candidateFull.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase) `
42
+ -and (Test-Path -LiteralPath $candidateFull -PathType Leaf)
43
+ if ($candidateValid) {
44
+ $configuredDshCli = $candidateFull
45
+ break
46
+ }
47
+ } catch { }
48
+ }
49
+ }
50
+ $dshCli = if ($configuredDshCli.Trim()) {
51
+ try { [IO.Path]::GetFullPath($configuredDshCli) } catch { $configuredDshCli }
52
+ } else {
53
+ Join-Path $crewHome 'runtime\node_modules\.bin\dsh.cmd'
54
+ }
55
+ if ($configuredDshCli.Trim()) { $env:DSH_CREW_DSH_CLI = $dshCli }
56
+ # DSH_HOME that owns the selected CLI. The managed npm runtime shares the Crew
57
+ # home; a Crew-managed source cohort is its own home (its profiles, sessions and
58
+ # settings live inside that tree). Derived from the selected entry so an explicit
59
+ # DSH_CREW_DSH_CLI override resolves correctly too.
60
+ $dshHome = $crewHome
61
+ $sourceRootMatch = [regex]::Match($dshCli, '^(?<root>.+?[\\/]runtime-source-[^\\/]+)[\\/]')
62
+ if ($sourceRootMatch.Success) {
63
+ $candidateRoot = $sourceRootMatch.Groups['root'].Value
64
+ if (Test-Path -LiteralPath (Join-Path $candidateRoot 'profiles') -PathType Container) { $dshHome = $candidateRoot }
65
+ }
66
+ $dshCliIsNodeEntry = [IO.Path]::GetExtension($dshCli).ToLowerInvariant() -eq '.js'
67
+ $dshCommand = if ($dshCliIsNodeEntry) {
68
+ (Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
69
+ } else { $dshCli }
70
+
71
+ function Get-CrewDshManifest {
72
+ param([string] $Entry)
73
+ if (-not $Entry) { return $null }
74
+ try { $cursor = [IO.Path]::GetFullPath($Entry) } catch { return $null }
75
+ if ([IO.Path]::GetExtension($cursor).ToLowerInvariant() -eq '.js') {
76
+ $cursor = Split-Path -Parent $cursor
77
+ } else {
78
+ $cursor = Split-Path -Parent $cursor
79
+ }
80
+ for ($depth = 0; $depth -lt 12 -and $cursor; $depth += 1) {
81
+ $candidates = @(
82
+ (Join-Path $cursor 'package.json'),
83
+ (Join-Path $cursor 'node_modules\@deepseek-ai\dsh\package.json')
84
+ )
85
+ foreach ($candidate in $candidates) {
86
+ if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue }
87
+ try {
88
+ $manifest = Get-Content -LiteralPath $candidate -Raw | ConvertFrom-Json
89
+ $knownOfficial = -not $configuredDshCli.Trim() -or $manifest.name -eq '@deepseek-ai/dsh'
90
+ if ($knownOfficial -and [string] $manifest.version) { return $manifest }
91
+ } catch { }
92
+ }
93
+ $parent = Split-Path -Parent $cursor
94
+ if ($parent -eq $cursor) { break }
95
+ $cursor = $parent
96
+ }
97
+ return $null
98
+ }
99
+
100
+ $dshManifest = Get-CrewDshManifest -Entry $dshCli
101
+ $logRoot = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
102
+ $launcherLog = Join-Path $logRoot 'dsh-crew-launcher.log'
103
+ $startedAt = Get-Date
104
+ $launcherProcessStartedAtUtcTicks = try { (Get-Process -Id $PID -ErrorAction Stop).StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture) } catch { $null }
105
+ $supervisorInstanceId = [guid]::NewGuid().ToString()
106
+ $launcherHelperHash = $null
107
+ try {
108
+ $hashStream = [System.IO.File]::OpenRead($PSCommandPath)
109
+ try {
110
+ $sha256 = [System.Security.Cryptography.SHA256]::Create()
111
+ try { $launcherHelperHash = (($sha256.ComputeHash($hashStream) | ForEach-Object { $_.ToString('x2') }) -join '') }
112
+ finally { $sha256.Dispose() }
113
+ } finally { $hashStream.Dispose() }
114
+ } catch { $launcherHelperHash = $null }
115
+ $services = @(
116
+ [pscustomobject]@{ Name = 'Crew backend'; Profile = 'dsh-crew'; Home = $dshHome; Port = 3210; Url = 'http://127.0.0.1:3210'; CrewOwned = $true; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null }
117
+ )
118
+
119
+ function Write-LaunchLog {
120
+ param([string] $Message, [ValidateSet('INFO', 'WARN', 'ERROR')] [string] $Level = 'INFO')
121
+ $line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffK'), $Level, $Message
122
+ Add-Content -LiteralPath $launcherLog -Value $line -Encoding UTF8
123
+ if ($Mode -eq 'open') {
124
+ if ($Level -eq 'ERROR') { Write-Host $line -ForegroundColor Red }
125
+ elseif ($Level -eq 'WARN') { Write-Host $line -ForegroundColor Yellow }
126
+ else { Write-Host $line }
127
+ }
128
+ }
129
+
130
+ function Resolve-OfficialHarnessCommand {
131
+ $shim = Get-Command dsh.cmd -CommandType Application -ErrorAction Stop | Select-Object -First 1
132
+ $root = Join-Path (Split-Path -Parent $shim.Source) 'node_modules\@deepseek-ai\dsh'
133
+ $manifest = Get-Content -LiteralPath (Join-Path $root 'package.json') -Raw | ConvertFrom-Json
134
+ if ($manifest.name -ne '@deepseek-ai/dsh') { throw 'The dsh command is not a verified official Harness installation.' }
135
+ $entrySpec = [string] $manifest.bin.dsh
136
+ if (-not $entrySpec -or [IO.Path]::IsPathRooted($entrySpec) -or $entrySpec.Contains(':') -or ($entrySpec.Replace('\', '/').Split('/') -contains '..')) {
137
+ throw 'The official Harness CLI entry is invalid.'
138
+ }
139
+ $entry = [IO.Path]::GetFullPath((Join-Path $root $entrySpec))
140
+ $rootPrefix = [IO.Path]::GetFullPath($root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
141
+ if (-not $entry.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase) -or -not (Test-Path -LiteralPath $entry -PathType Leaf)) {
142
+ throw 'The official Harness CLI entry was not found.'
143
+ }
144
+ $node = Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1
145
+ return [pscustomobject]@{ NodePath = $node.Source; Entry = $entry }
146
+ }
147
+
148
+ function Test-OfficialHarnessListener {
149
+ param([int] $OwnerPid, [pscustomobject] $Official, [string] $Profile = 'web')
150
+ if ($OwnerPid -le 0) { return $false }
151
+ try {
152
+ $owner = Get-CimInstance Win32_Process -Filter ('ProcessId={0}' -f $OwnerPid) -ErrorAction Stop
153
+ if (-not $owner -or [IO.Path]::GetFileName([string] $owner.ExecutablePath) -ine 'node.exe') { return $false }
154
+ $line = ([string] $owner.CommandLine).Replace('/', '\')
155
+ $entry = [regex]::Escape($Official.Entry.Replace('/', '\'))
156
+ $node = [regex]::Escape($Official.NodePath.Replace('/', '\'))
157
+ $profileToken = [regex]::Escape($Profile)
158
+ $pattern = '^\s*(?:"?' + $node + '"?|"?node(?:\.exe)?"?)\s+"?' + $entry + '"?\s+(?:"?' + $profileToken + '"?|--profile\s+"?' + $profileToken + '"?)(?:\s|$)'
159
+ return $line -match $pattern
160
+ } catch { return $false }
161
+ }
162
+
163
+ function Test-CrewDshCliPreflight {
164
+ if ($null -eq $dshManifest -or [string]::IsNullOrWhiteSpace([string] $dshManifest.version)) {
165
+ throw "The DSH CLI at $dshCli is not a verified @deepseek-ai/dsh package entry."
166
+ }
167
+ if ($dshCliIsNodeEntry -and -not (Test-Path -LiteralPath (Join-Path (Join-Path $dshHome 'profiles') 'web\package.json') -PathType Leaf)) {
168
+ throw ('The Harness web profile is missing under {0}. Run: dsh-crew update' -f $dshHome)
169
+ }
170
+ $versionArgs = if ($dshCliIsNodeEntry) { @($dshCli, '--version') } else { @('--version') }
171
+ $reported = try { (& $dshCommand @versionArgs 2>&1 | Out-String).Trim() } catch { '' }
172
+ if ($LASTEXITCODE -ne 0 -or $reported -ne [string] $dshManifest.version) {
173
+ throw ('DSH CLI preflight failed: manifest={0}, reported={1}' -f $dshManifest.version, ($reported -replace '\s+', ' '))
174
+ }
175
+ }
176
+
177
+ function Get-CrewWebSessionUrl {
178
+ param([string] $OutputLog = $null)
179
+ $logs = if ($OutputLog) {
180
+ @(Get-Item -LiteralPath $OutputLog -ErrorAction SilentlyContinue)
181
+ } else {
182
+ @(Get-ChildItem -LiteralPath $logRoot -Filter 'dsh-crew-web-*.out.log' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
183
+ }
184
+ foreach ($log in $logs) {
185
+ try {
186
+ $text = Get-Content -LiteralPath $log.FullName -Raw -ErrorAction Stop
187
+ $match = [regex]::Match($text, 'https?://127\.0\.0\.1:3080/\?token=[^\s\r\n]+')
188
+ if ($match.Success) { return $match.Value }
189
+ } catch { }
190
+ }
191
+ return $null
192
+ }
193
+
194
+ function Open-CrewBrowserUrl {
195
+ param([Parameter(Mandatory = $true)] [string] $Url)
196
+ $edge = Get-Command msedge.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
197
+ if (-not $edge) {
198
+ $edgeCandidates = @(
199
+ @(
200
+ (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
201
+ (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe'),
202
+ (Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\Application\msedge.exe')
203
+ ) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) }
204
+ )
205
+ if ($edgeCandidates.Count -gt 0) { $edge = [pscustomobject]@{ Source = $edgeCandidates[0] } }
206
+ }
207
+ if ($edge) {
208
+ Start-Process -FilePath $edge.Source -ArgumentList @('--new-window', $Url) | Out-Null
209
+ return
210
+ }
211
+ Start-Process $Url | Out-Null
212
+ }
213
+
214
+ function Test-CrewWebSessionUrl {
215
+ param([string] $Url)
216
+ if (-not $Url) { return $false }
217
+ try {
218
+ # The token endpoint intentionally answers with a 303 + Set-Cookie and
219
+ # redirects to `/`; do not follow it here or PowerShell loses the cookie
220
+ # and falsely reports the authenticated page as 404.
221
+ $response = Invoke-WebRequest -UseBasicParsing -MaximumRedirection 0 -Uri $Url -TimeoutSec 3
222
+ return [int] $response.StatusCode -ge 200 -and [int] $response.StatusCode -lt 400
223
+ } catch {
224
+ $status = if ($_.Exception.Response) { [int] $_.Exception.Response.StatusCode } else { 0 }
225
+ return $status -in @(301, 302, 303, 307, 308)
226
+ }
227
+ }
228
+
229
+ function Open-CrewManagedFrontend {
230
+ param([int] $TimeoutSeconds = 90)
231
+ # The 3080 frontend boots from the same Crew-managed Harness entry as 3210,
232
+ # against that entry's own DSH_HOME (npm runtime -> Crew home, source cohort
233
+ # -> its own tree). No official ~/.dsh state is read or written here.
234
+ $managedHome = $dshHome
235
+ $managedProfile = 'web'
236
+ $profileManifest = Join-Path (Join-Path $managedHome 'profiles') ('{0}\package.json' -f $managedProfile)
237
+ if (-not $dshCliIsNodeEntry -or -not (Test-Path -LiteralPath $profileManifest -PathType Leaf)) {
238
+ return $false
239
+ }
240
+ $official = [pscustomobject]@{ NodePath = $dshCommand; Entry = $dshCli; Profile = $managedProfile }
241
+ $frontend = Get-OfficialFrontendOverlay
242
+ $mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
243
+ $locked = $false
244
+ try {
245
+ try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
246
+ if (-not $locked) { throw 'Another desktop launch is still starting the Crew-managed Harness frontend.' }
247
+ $port = Get-PortState -Port 3080
248
+ if ($port.State -eq 'unknown') { throw $port.Error }
249
+ if ($port.State -eq 'occupied') {
250
+ if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official -Profile $managedProfile)) {
251
+ throw 'Port 3080 is occupied by a non-Crew-managed process; it was left untouched.'
252
+ }
253
+ if (-not (Test-OfficialWebReady)) { throw 'The Crew-managed 3080 frontend is not ready; its process was left running.' }
254
+ $existingUrl = Get-CrewWebSessionUrl
255
+ if ($existingUrl -and (Test-CrewWebSessionUrl -Url $existingUrl)) {
256
+ Open-CrewBrowserUrl -Url $existingUrl
257
+ Write-LaunchLog 'Opened the existing Crew-managed Harness frontend on 3080 with its session URL.'
258
+ return $true
259
+ }
260
+ Write-LaunchLog 'The existing Crew-managed 3080 session URL is stale; restarting the verified listener.' 'WARN'
261
+ Stop-Process -Id $port.Pid -Force -ErrorAction Stop
262
+ $freeDeadline = (Get-Date).AddSeconds(5)
263
+ do { Start-Sleep -Milliseconds 250; $port = Get-PortState -Port 3080 } while ($port.State -ne 'free' -and (Get-Date) -lt $freeDeadline)
264
+ if ($port.State -ne 'free') { throw 'The stale Crew-managed 3080 listener did not release the port.' }
265
+ }
266
+
267
+ $stamp = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmssfff'), $PID
268
+ $stdout = Join-Path $logRoot ('dsh-crew-web-{0}.out.log' -f $stamp)
269
+ $stderr = Join-Path $logRoot ('dsh-crew-web-{0}.err.log' -f $stamp)
270
+ $previousHome = $env:DSH_HOME
271
+ $previousCli = $env:DSH_CREW_DSH_CLI
272
+ try {
273
+ $env:DSH_HOME = $managedHome
274
+ $env:DSH_CREW_DSH_CLI = $dshCli
275
+ $arguments = @($dshCli, '--profile', $managedProfile, '--patch', ('"{0}"' -f $frontend.Path), '--host', '127.0.0.1', '--port', '3080')
276
+ $process = Start-Process -FilePath $dshCommand -ArgumentList $arguments -WindowStyle Hidden -PassThru `
277
+ -RedirectStandardOutput $stdout -RedirectStandardError $stderr
278
+ } finally {
279
+ $env:DSH_HOME = $previousHome
280
+ $env:DSH_CREW_DSH_CLI = $previousCli
281
+ }
282
+ Write-LaunchLog ('Started Crew-managed Harness on 3080; PID={0}. Logs: {1}, {2}' -f $process.Id, $stdout, $stderr)
283
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
284
+ do {
285
+ $port = Get-PortState -Port 3080
286
+ if ($port.State -eq 'occupied') {
287
+ if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official -Profile $managedProfile)) {
288
+ throw 'Port 3080 became occupied by an unverified process; it was left untouched.'
289
+ }
290
+ if (Test-OfficialWebReady) {
291
+ $startedUrl = Get-CrewWebSessionUrl -OutputLog $stdout
292
+ if ($startedUrl) { Open-CrewBrowserUrl -Url $startedUrl; return $true }
293
+ }
294
+ }
295
+ if ($process.HasExited) { throw ('Crew-managed Harness exited before readiness. Diagnostic log: {0}' -f $stderr) }
296
+ Start-Sleep -Milliseconds 500
297
+ } while ((Get-Date) -lt $deadline)
298
+ throw ('The Crew-managed Harness did not become ready within {0}s; inspect {1}.' -f $TimeoutSeconds, $stderr)
299
+ } finally {
300
+ if ($locked) { $mutex.ReleaseMutex() }
301
+ $mutex.Dispose()
302
+ }
303
+ }
304
+
305
+ function Test-OfficialWebReady {
306
+ try {
307
+ $response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:3080/' -TimeoutSec 2
308
+ return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300
309
+ } catch {
310
+ # An authenticated official UI may reject this cookie-free health request.
311
+ if ($_.Exception.Response) { return [int] $_.Exception.Response.StatusCode -in @(401, 403) }
312
+ return $false
313
+ }
314
+ }
315
+
316
+ function Get-OfficialFrontendOverlay {
317
+ $frontendRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\frontend'
318
+ $path = Join-Path $frontendRoot 'official-web.patch.json'
319
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'Crew frontend overlay is missing. Run dsh-crew update with the current GitHub candidate.' }
320
+ # Windows PowerShell 5.1 keeps a JSON root array as one pipeline object;
321
+ # direct `$patch[0].insert[0]` therefore resolves `insert` as IList.Insert
322
+ # instead of indexing the JSON property. Flatten both PowerShell generations
323
+ # explicitly and read properties through PSObject so StrictMode reports a
324
+ # useful validation error instead of "property id cannot be found".
325
+ $parsed = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
326
+ $patch = @($parsed | ForEach-Object { $_ })
327
+ $root = if ($patch.Count -eq 1) { $patch[0] } else { $null }
328
+ $insertProperty = if ($null -ne $root) { $root.PSObject.Properties['insert'] } else { $null }
329
+ $insertValue = if ($null -ne $insertProperty) { $insertProperty.Value } else { $null }
330
+ $inserts = @($insertValue | ForEach-Object { $_ })
331
+ $bridge = if ($inserts.Count -eq 1) { $inserts[0] } else { $null }
332
+ $idProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['id'] } else { $null }
333
+ $nameProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['name'] } else { $null }
334
+ if ($patch.Count -ne 1 -or $inserts.Count -ne 1 -or $null -eq $idProperty -or [string] $idProperty.Value -ne 'dsh-crew-official-web-bridge' -or $null -eq $nameProperty) {
335
+ throw 'Crew frontend overlay has an unexpected structure.'
336
+ }
337
+ $uri = [Uri] ([string] $nameProperty.Value)
338
+ if (-not $uri.IsFile) { throw 'Crew frontend must load from its local snapshot.' }
339
+ $entry = [IO.Path]::GetFullPath($uri.LocalPath)
340
+ $prefix = [IO.Path]::GetFullPath((Join-Path $frontendRoot 'revisions')) + [IO.Path]::DirectorySeparatorChar
341
+ if (-not $entry.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Crew frontend snapshot is outside its owned directory.' }
342
+ $metadata = Get-Content -LiteralPath (Join-Path (Split-Path -Parent $entry) 'package.json') -Raw | ConvertFrom-Json
343
+ $revision = [string] $metadata.dshCrewFrontendRevision
344
+ if ($metadata.dshCrewManagedFrontend -ne $true -or $revision -notmatch '^[a-f0-9]{64}$' -or -not (Test-Path -LiteralPath $entry -PathType Leaf)) {
345
+ throw 'Crew frontend snapshot is incomplete.'
346
+ }
347
+ return [pscustomobject]@{ Path = $path; Revision = $revision }
348
+ }
349
+
350
+ function Test-OfficialFrontendAttached {
351
+ param([string] $Revision)
352
+ try {
353
+ $response = Invoke-RestMethod -Uri 'http://127.0.0.1:3080/_dsh/dsh-crew/bridge-status' -TimeoutSec 2
354
+ return $response.ok -eq $true -and $response.surface -eq 'official-bridge' -and $response.frontend_revision -eq $Revision
355
+ } catch { return $false }
356
+ }
357
+
358
+ function Open-OfficialFrontend {
359
+ param([int] $TimeoutSeconds = 90)
360
+ # Preferred path: the Crew-managed Harness serves 3080 with the Crew panel
361
+ # already loaded. The legacy fallback below (separate official install in
362
+ # ~/.dsh) is only reached when no Crew-managed frontend is available.
363
+ if (Open-CrewManagedFrontend -TimeoutSeconds $TimeoutSeconds) { return }
364
+ $official = Resolve-OfficialHarnessCommand
365
+ $frontend = Get-OfficialFrontendOverlay
366
+ $mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
367
+ $locked = $false
368
+ try {
369
+ try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
370
+ if (-not $locked) { throw 'Another desktop launch is still starting the official frontend.' }
371
+ $port = Get-PortState -Port 3080
372
+ if ($port.State -eq 'unknown') { throw $port.Error }
373
+ if ($port.State -eq 'occupied') {
374
+ if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 is occupied by an unverified process; it was left untouched.' }
375
+ if (-not (Test-OfficialWebReady)) { throw 'The official 3080 frontend is not ready; its process was left running.' }
376
+ if (-not (Test-OfficialFrontendAttached -Revision $frontend.Revision)) { throw 'The running official frontend has not loaded the current Crew panel. Stop that official instance when idle, then launch from the desktop again. It was left untouched.' }
377
+ Start-Process 'http://127.0.0.1:3080/' | Out-Null
378
+ Write-LaunchLog 'Opened the existing official Harness frontend on 3080.'
379
+ return
380
+ }
381
+
382
+ $stamp = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmssfff'), $PID
383
+ $stdout = Join-Path $logRoot ('dsh-official-web-{0}.out.log' -f $stamp)
384
+ $stderr = Join-Path $logRoot ('dsh-official-web-{0}.err.log' -f $stamp)
385
+ $previousHome = $env:DSH_HOME
386
+ try {
387
+ # The official application owns its normal state; no Crew plugin/profile
388
+ # registration or repair is performed in this home by the launcher.
389
+ $env:DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
390
+ $arguments = @(('"{0}"' -f $official.Entry), 'web', '--patch', ('"{0}"' -f $frontend.Path), '--host', '127.0.0.1', '--port', '3080')
391
+ $process = Start-Process -FilePath $official.NodePath -ArgumentList $arguments -WindowStyle Hidden -PassThru `
392
+ -RedirectStandardOutput $stdout -RedirectStandardError $stderr
393
+ } finally { $env:DSH_HOME = $previousHome }
394
+ Write-LaunchLog ('Started official Harness on 3080; PID={0}. The official CLI opens the browser. Logs: {1}, {2}' -f $process.Id, $stdout, $stderr)
395
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
396
+ $nextProgress = (Get-Date).AddSeconds(5)
397
+ do {
398
+ $port = Get-PortState -Port 3080
399
+ if ($port.State -eq 'occupied') {
400
+ if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 became occupied by an unverified process; it was left untouched.' }
401
+ if ((Test-OfficialWebReady) -and (Test-OfficialFrontendAttached -Revision $frontend.Revision)) { return }
402
+ }
403
+ if ($process.HasExited) { throw ('Official Harness exited before readiness. Diagnostic log: {0}' -f $stderr) }
404
+ if ((Get-Date) -ge $nextProgress) {
405
+ Write-LaunchLog 'Waiting for the official Harness frontend on 3080...'
406
+ $nextProgress = (Get-Date).AddSeconds(5)
407
+ }
408
+ Start-Sleep -Milliseconds 500
409
+ } while ((Get-Date) -lt $deadline)
410
+ throw ('Official Harness did not become ready within {0}s; inspect {1}. No official process was stopped.' -f $TimeoutSeconds, $stderr)
411
+ } finally {
412
+ if ($locked) { $mutex.ReleaseMutex() }
413
+ $mutex.Dispose()
414
+ }
415
+ }
416
+
417
+ function Get-HealthState {
418
+ param([pscustomObject] $Service)
419
+ # Preserve the installed extension health contract used by maintenance.
420
+ if ($Service.CrewOwned) {
421
+ try {
422
+ $response = Invoke-RestMethod -Uri ($Service.Url + '/_dsh/dsh-crew/extension') -TimeoutSec 2
423
+ $runtime = $response.extension.runtime
424
+ $version = $runtime.runtime_version
425
+ # A stale 3210 process (booted before the runtime tree was swapped) can
426
+ # keep serving from memory while the disk tree is a different cohort.
427
+ # Require the reported dsh_version to equal the installed disk
428
+ # @deepseek-ai/dsh version so supervisor never treats a
429
+ # disk-rc.1/memory-alpha.5 (or vice versa) process as healthy.
430
+ # FAIL CLOSED: an unreadable/missing disk manifest is NOT healthy —
431
+ # the process cannot be proven to match the tree it will next boot.
432
+ $expectedDshVersion = $null
433
+ $diskReadable = $false
434
+ $activeDshManifest = Get-CrewDshManifest -Entry $dshCli
435
+ if ($null -ne $activeDshManifest -and [string] $activeDshManifest.version) {
436
+ $expectedDshVersion = [string] $activeDshManifest.version
437
+ $diskReadable = $true
438
+ }
439
+ $cohortMatches = $diskReadable -and $runtime.dsh_version -eq $expectedDshVersion
440
+ if ($response.ok -eq $true -and $version -and $cohortMatches) {
441
+ return [pscustomobject]@{ Ready = $true; Version = [string] $version; Error = $null }
442
+ }
443
+ if (-not $diskReadable) {
444
+ return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'disk runtime manifest unreadable; cannot prove cohort identity' }
445
+ }
446
+ if ($version -and -not $cohortMatches) {
447
+ return [pscustomobject]@{ Ready = $false; Version = [string] $version; Error = ('runtime cohort mismatch: hub reports dsh_version={0} but disk runtime is {1}' -f $runtime.dsh_version, $expectedDshVersion) }
448
+ }
449
+ return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'Response did not contain a ready runtime contract.' }
450
+ } catch {
451
+ return [pscustomobject]@{ Ready = $false; Version = $null; Error = $_.Exception.Message }
452
+ }
453
+ }
454
+ # The launcher supervises only the Crew-owned 3210 service. Any non-Crew
455
+ # service is never started or health-gated here; the legacy 3080 bridge
456
+ # is probed only diagnostically after 3210 readiness (see Write-LegacyBridgeDiagnostic).
457
+ return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'Unknown service: only the Crew-owned 3210 service is supervised.' }
458
+ }
459
+
460
+ # Optional legacy compatibility probe: returns true only when a 3080 that
461
+ # still hosts the Crew bridge answers its ping endpoint. The Crew launcher
462
+ # never depends on it; 3210 always boots directly first.
463
+ function Test-LegacyBridgeAvailable {
464
+ try {
465
+ $response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:3080/_dsh/dsh-crew/ping' -TimeoutSec 3
466
+ return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300)
467
+ } catch {
468
+ return $false
469
+ }
470
+ }
471
+
472
+ # ---- Crew supervisor control protocol (heartbeat + restart requests) --------
473
+ # The hub (3210) never spawns itself. It writes a durable restart request;
474
+ # this watcher is the only process authority and executes it after proving
475
+ # ownership (persisted identity + live runtime_id match).
476
+
477
+ $crewSupervisorRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\supervisor'
478
+ $crewHeartbeatFile = Join-Path $crewSupervisorRoot 'heartbeat.json'
479
+ $crewRequestsDir = Join-Path $crewSupervisorRoot 'restart-requests'
480
+ $crewResultsDir = Join-Path $crewSupervisorRoot 'restart-results'
481
+
482
+ # UTF-8 WITHOUT BOM: Windows PowerShell 5.1's Set-Content -Encoding UTF8
483
+ # writes a BOM, and the Node hub's JSON.parse rejects it (heartbeat/request
484
+ # files would be unreadable -> supervisor judged unavailable).
485
+ function Write-Utf8NoBom {
486
+ param([string] $Path, [string] $Content)
487
+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
488
+ [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
489
+ }
490
+
491
+ function Write-SupervisorHeartbeat {
492
+ param([bool] $OwnershipReady = $false)
493
+ $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
494
+ $record = @{ schema_version = 1; supervisor_instance_id = $supervisorInstanceId; pid = $PID; process_started_at_utc_ticks = $launcherProcessStartedAtUtcTicks; helper_hash = $launcherHelperHash; ownership_ready = $OwnershipReady; last_seen = $now; protocol_version = 1 } | ConvertTo-Json -Compress
495
+ try {
496
+ if (-not (Test-Path -LiteralPath $crewSupervisorRoot -PathType Container)) { New-Item -ItemType Directory -Path $crewSupervisorRoot -Force | Out-Null }
497
+ $temp = Join-Path $crewSupervisorRoot ("heartbeat.{0}.tmp" -f $PID)
498
+ Write-Utf8NoBom -Path $temp -Content $record
499
+ Move-Item -LiteralPath $temp -Destination $crewHeartbeatFile -Force
500
+ } catch { /* heartbeat is best-effort */ }
501
+ }
502
+
503
+ function Get-SupervisorLaunchArguments {
504
+ param([string] $ScriptPath = $PSCommandPath)
505
+ if ([string]::IsNullOrWhiteSpace($ScriptPath) -or $ScriptPath.Contains('"')) {
506
+ throw 'A valid launcher script path is required.'
507
+ }
508
+ return @(
509
+ '-NoLogo',
510
+ '-NoProfile',
511
+ '-NonInteractive',
512
+ '-ExecutionPolicy', 'Bypass',
513
+ '-File', ('"{0}"' -f $ScriptPath),
514
+ '-Mode', 'watch'
515
+ )
516
+ }
517
+
518
+ function Get-SupervisorHeartbeatRecord {
519
+ param([int] $MaxAgeSeconds = 30)
520
+ try {
521
+ if (-not (Test-Path -LiteralPath $crewHeartbeatFile -PathType Leaf)) { return $null }
522
+ $record = Get-Content -LiteralPath $crewHeartbeatFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
523
+ $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
524
+ $lastSeen = [long] $record.last_seen
525
+ $age = $now - $lastSeen
526
+ if ($record.schema_version -ne 1 -or $record.protocol_version -ne 1 -or [int] $record.pid -le 0) { return $null }
527
+ if ($age -lt -5000 -or $age -gt ([long] $MaxAgeSeconds * 1000)) { return $null }
528
+ $process = Get-Process -Id ([int] $record.pid) -ErrorAction Stop
529
+ $processStartProperty = $record.PSObject.Properties['process_started_at_utc_ticks']
530
+ if ($null -ne $processStartProperty -and $processStartProperty.Value) {
531
+ $expectedTicks = [long] $processStartProperty.Value
532
+ if ($process.StartTime.ToUniversalTime().Ticks -ne $expectedTicks) { return $null }
533
+ }
534
+ $ownershipProperty = $record.PSObject.Properties['ownership_ready']
535
+ $helperHashProperty = $record.PSObject.Properties['helper_hash']
536
+ $instanceProperty = $record.PSObject.Properties['supervisor_instance_id']
537
+ $identityReady = $null -ne $processStartProperty -and $processStartProperty.Value `
538
+ -and $null -ne $helperHashProperty -and $helperHashProperty.Value -match '^[a-f0-9]{64}$' `
539
+ -and $null -ne $instanceProperty -and -not [string]::IsNullOrWhiteSpace([string] $instanceProperty.Value)
540
+ $state = if ($null -eq $ownershipProperty) { 'legacy-v1' } elseif ($ownershipProperty.Value -eq $true -and $identityReady) { 'ready' } else { 'starting' }
541
+ return [pscustomobject]@{ State = $state; Record = $record }
542
+ } catch {
543
+ return $null
544
+ }
545
+ }
546
+
547
+ function Get-FreshSupervisorHeartbeat {
548
+ param([int] $MaxAgeSeconds = 30)
549
+ $observed = Get-SupervisorHeartbeatRecord -MaxAgeSeconds $MaxAgeSeconds
550
+ if ($observed -and $observed.State -eq 'ready') { return $observed.Record }
551
+ return $null
552
+ }
553
+
554
+ function Ensure-CrewSupervisorRunning {
555
+ param([int] $TimeoutSeconds = 90)
556
+ $watcher = $null
557
+ $observed = Get-SupervisorHeartbeatRecord
558
+ if ($observed -and $observed.State -eq 'legacy-v1') {
559
+ throw 'CREW_SUPERVISOR_UPGRADE_REQUIRED: a legacy watcher is active and must be handed off before interactive launch.'
560
+ }
561
+ if (-not $observed) {
562
+ $arguments = @(Get-SupervisorLaunchArguments -ScriptPath $PSCommandPath)
563
+ $watcher = Start-Process -FilePath 'powershell.exe' -ArgumentList $arguments -WindowStyle Hidden -PassThru
564
+ Write-LaunchLog ('Started persistent Crew supervisor; PID={0}.' -f $watcher.Id)
565
+ }
566
+
567
+ $crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
568
+ if (-not $crew) { throw 'Crew-owned 3210 service definition is missing.' }
569
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
570
+ $nextProgress = (Get-Date).AddSeconds(5)
571
+ $lastHealthError = 'not checked'
572
+ do {
573
+ $heartbeat = Get-FreshSupervisorHeartbeat
574
+ $health = Get-HealthState $crew
575
+ $lastHealthError = $health.Error
576
+ if ($heartbeat -and $health.Ready) {
577
+ Write-LaunchLog ('Persistent supervisor ready; PID={0}; 3210 runtime={1}.' -f $heartbeat.pid, $health.Version)
578
+ return
579
+ }
580
+ if ((Get-Date) -ge $nextProgress) {
581
+ Write-LaunchLog ('Waiting for Crew on 3210; {0:n0}s remaining. Health: {1}' -f [Math]::Max(0, ($deadline - (Get-Date)).TotalSeconds), $lastHealthError)
582
+ $nextProgress = (Get-Date).AddSeconds(5)
583
+ }
584
+ Start-Sleep -Milliseconds 500
585
+ } while ((Get-Date) -lt $deadline)
586
+
587
+ throw ('Persistent supervisor did not make 3210 ready within {0}s. Last health error: {1}' -f $TimeoutSeconds, $lastHealthError)
588
+ }
589
+
590
+ function Read-RestartRequests {
591
+ if (-not (Test-Path -LiteralPath $crewRequestsDir -PathType Container)) { return @() }
592
+ $requests = @()
593
+ Get-ChildItem -LiteralPath $crewRequestsDir -Filter '*.json' -File -ErrorAction SilentlyContinue | ForEach-Object {
594
+ try {
595
+ $parsed = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json -ErrorAction Stop
596
+ if ($parsed.schema_version -eq 1 -and $parsed.operation -eq 'restart' -and $parsed.request_id) {
597
+ $requests += $parsed
598
+ }
599
+ } catch { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }
600
+ }
601
+ return $requests
602
+ }
603
+
604
+ function Write-RestartResult {
605
+ param([object] $Request, [string] $State, [object] $Detail = $null)
606
+ try {
607
+ if (-not (Test-Path -LiteralPath $crewResultsDir -PathType Container)) { New-Item -ItemType Directory -Path $crewResultsDir -Force | Out-Null }
608
+ $result = @{
609
+ schema_version = 1
610
+ request_id = $Request.request_id
611
+ operation = 'restart'
612
+ state = $State
613
+ runtime_id = $Request.runtime_id
614
+ written_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
615
+ detail = $Detail
616
+ } | ConvertTo-Json -Depth 5
617
+ $file = Join-Path $crewResultsDir ("{0}.json" -f $Request.request_id)
618
+ $temp = Join-Path $crewResultsDir ("{0}.{1}.tmp" -f $Request.request_id, $PID)
619
+ Write-Utf8NoBom -Path $temp -Content $result
620
+ Move-Item -LiteralPath $temp -Destination $file -Force
621
+ } catch { /* best effort */ }
622
+ Remove-Item -LiteralPath (Join-Path $crewRequestsDir ("{0}.json" -f $Request.request_id)) -Force -ErrorAction SilentlyContinue
623
+ }
624
+
625
+ function Invoke-CrewRestartRequests {
626
+ # Consume durable restart requests from the hub. Called by the watch loop.
627
+ $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
628
+ foreach ($request in (Read-RestartRequests)) {
629
+ if (($request.expires_at -as [long]) -lt $now) {
630
+ Write-RestartResult $request 'RESTART_REQUEST_EXPIRED'
631
+ Write-LaunchLog ('Restart request {0} expired; not executed.' -f $request.request_id) 'WARN'
632
+ continue
633
+ }
634
+ # Ownership authority: the request must name the SAME runtime identity we
635
+ # own, and that identity must still be live on 3210.
636
+ $crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
637
+ if (-not $crew) { continue }
638
+ $health = Get-HealthState $crew
639
+ $liveIdentityOk = $false
640
+ try {
641
+ $response = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
642
+ $liveRuntimeId = $response.extension.runtime.runtime_id
643
+ $liveIdentityOk = ($response.ok -eq $true) -and ($liveRuntimeId -eq $request.runtime_id)
644
+ } catch { $liveIdentityOk = $false }
645
+ if (-not $liveIdentityOk) {
646
+ Write-RestartResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
647
+ Write-LaunchLog ('Restart request {0} rejected: live runtime_id does not match the request.' -f $request.request_id) 'WARN'
648
+ continue
649
+ }
650
+ Write-LaunchLog ('Executing restart request {0} (reason: {1}).' -f $request.request_id, $request.reason)
651
+ $previousRuntimeId = $request.runtime_id
652
+ # Re-read the listener port right before the kill so the PID identity is
653
+ # fresh: Stop-OwnedListener requires a PID that equals the tracked
654
+ # listener and belongs to the current owned process tree.
655
+ $port = Get-PortState $crew.Port
656
+ if ($port.State -ne 'occupied' -or -not $port.Pid) {
657
+ Write-RestartResult $request 'SUPERVISOR_STOP_FAILED'
658
+ Write-LaunchLog ('Restart request {0} failed: no listener on port {1}.' -f $request.request_id, $crew.Port) 'ERROR'
659
+ continue
660
+ }
661
+ $stopped = Stop-OwnedListener -Service $crew -ListenerPid ([int] $port.Pid)
662
+ if (-not $stopped) {
663
+ Write-RestartResult $request 'SUPERVISOR_STOP_FAILED'
664
+ Write-LaunchLog ('Restart request {0} failed to stop the owned runtime.' -f $request.request_id) 'ERROR'
665
+ continue
666
+ }
667
+ Start-CrewService $crew
668
+ Wait-CrewServices
669
+ # Verify: runtime_id must have changed and the cohort must still match.
670
+ $newRuntimeId = $null
671
+ try {
672
+ $response = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
673
+ $newRuntimeId = $response.extension.runtime.runtime_id
674
+ } catch { $newRuntimeId = $null }
675
+ if ($newRuntimeId -and $newRuntimeId -ne $previousRuntimeId) {
676
+ Write-RestartResult $request 'VERIFIED' @{ previous_runtime_id = $previousRuntimeId; runtime_id = $newRuntimeId }
677
+ Write-LaunchLog ('Restart request {0} verified: runtime_id {1} -> {2}.' -f $request.request_id, $previousRuntimeId, $newRuntimeId)
678
+ } else {
679
+ Write-RestartResult $request 'VERIFY_FAILED' @{ previous_runtime_id = $previousRuntimeId; runtime_id = $newRuntimeId }
680
+ Write-LaunchLog ('Restart request {0} verification failed: runtime_id did not change.' -f $request.request_id) 'ERROR'
681
+ }
682
+ }
683
+ }
684
+
685
+ $crewMaintenanceRequestsDir = Join-Path $crewSupervisorRoot 'maintenance-requests'
686
+ $crewMaintenanceResultsDir = Join-Path $crewSupervisorRoot 'maintenance-results'
687
+ $crewMaintenanceSessionFile = Join-Path $crewSupervisorRoot 'maintenance-session.json'
688
+
689
+ function Test-MaintenanceSessionActive {
690
+ # Fence for ordinary Crew auto-start. Returns $true when a STOPPED session
691
+ # holds the launch right OR when the session file is present but MALFORMED
692
+ # (fail closed: a corrupt session must not let supervision auto-start
693
+ # 3210, because the stopped window may belong to a live npx swap).
694
+ # ABSENT means no fence: ordinary supervision proceeds.
695
+ $session = Read-MaintenanceSession
696
+ if ($session -eq 'ABSENT') { return $false }
697
+ return $true
698
+ }
699
+
700
+ function Set-MaintenanceSession {
701
+ param([object] $Request)
702
+ # Strong-failure semantics: the STOPPED result may only be published after
703
+ # the session is durably written AND read back with exact identity. Any
704
+ # failure returns $false and the caller must NOT claim the stopped window.
705
+ try {
706
+ $session = @{
707
+ schema_version = 1
708
+ state = 'STOPPED'
709
+ lease = $Request.lease
710
+ runtime_id = $Request.runtime_id
711
+ stopped_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
712
+ request_id = $Request.request_id
713
+ } | ConvertTo-Json -Compress
714
+ $temp = Join-Path $crewSupervisorRoot ("maintenance-session.{0}.tmp" -f $PID)
715
+ Write-Utf8NoBom -Path $temp -Content $session
716
+ Move-Item -LiteralPath $temp -Destination $crewMaintenanceSessionFile -Force
717
+ $back = Read-MaintenanceSession
718
+ if ($back -is [string]) { return $false }
719
+ return ($back.schema_version -eq 1 -and $back.state -eq 'STOPPED' -and $back.lease -eq $Request.lease -and $back.runtime_id -eq $Request.runtime_id -and $back.request_id -eq $Request.request_id)
720
+ } catch { return $false }
721
+ }
722
+
723
+ function Clear-MaintenanceSession {
724
+ # Verifiable one-shot consumption: returns $true only when the session
725
+ # file is provably ABSENT afterwards (otherwise the lease is NOT consumed
726
+ # and replay stays impossible because start re-validates the session).
727
+ try {
728
+ Remove-Item -LiteralPath $crewMaintenanceSessionFile -Force -ErrorAction Stop
729
+ } catch {
730
+ if (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf) { return $false }
731
+ return $true
732
+ }
733
+ return (-not (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf))
734
+ }
735
+
736
+ function Read-MaintenanceSession {
737
+ # Tri-state: 'ABSENT' (no file), a session object when valid, or the
738
+ # string 'MALFORMED' (present but unreadable/invalid). MALFORMED fails
739
+ # closed for Crew auto-start (see Test-MaintenanceSessionActive).
740
+ try {
741
+ if (-not (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf)) { return 'ABSENT' }
742
+ $parsed = Get-Content -LiteralPath $crewMaintenanceSessionFile -Raw | ConvertFrom-Json -ErrorAction Stop
743
+ if ($parsed.schema_version -ne 1 -or $parsed.state -ne 'STOPPED' -or -not $parsed.lease -or -not $parsed.runtime_id -or -not $parsed.request_id) {
744
+ return 'MALFORMED'
745
+ }
746
+ return $parsed
747
+ } catch { return 'MALFORMED' }
748
+ }
749
+
750
+ function Write-MaintenanceResult {
751
+ param([object] $Request, [string] $State, [object] $Detail = $null)
752
+ try {
753
+ if (-not (Test-Path -LiteralPath $crewMaintenanceResultsDir -PathType Container)) { New-Item -ItemType Directory -Path $crewMaintenanceResultsDir -Force | Out-Null }
754
+ $result = @{
755
+ schema_version = 1
756
+ request_id = $Request.request_id
757
+ operation = $Request.operation
758
+ state = $State
759
+ lease = $Request.lease
760
+ runtime_id = $Request.runtime_id
761
+ written_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
762
+ detail = $Detail
763
+ } | ConvertTo-Json -Depth 5
764
+ $file = Join-Path $crewMaintenanceResultsDir ("{0}.json" -f $Request.request_id)
765
+ $temp = Join-Path $crewMaintenanceResultsDir ("{0}.{1}.tmp" -f $Request.request_id, $PID)
766
+ Write-Utf8NoBom -Path $temp -Content $result
767
+ Move-Item -LiteralPath $temp -Destination $file -Force
768
+ } catch { /* best effort */ }
769
+ Remove-Item -LiteralPath (Join-Path $crewMaintenanceRequestsDir ("{0}.json" -f $Request.request_id)) -Force -ErrorAction SilentlyContinue
770
+ }
771
+
772
+ function Invoke-CrewMaintenanceRequests {
773
+ # Consume maintenance transactions from npx lifecycle (cohort migration).
774
+ # The npx process owns the runtime TREE swap; this watcher owns the
775
+ # PROCESS stop/start around it. Two phases, both verified:
776
+ # maintenance-stop -> stop owned 3210, write STOPPED (+lease)
777
+ # maintenance-start -> start 3210, verify new identity + cohort, VERIFIED
778
+ $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
779
+ if (-not (Test-Path -LiteralPath $crewMaintenanceRequestsDir -PathType Container)) { return }
780
+ $files = Get-ChildItem -LiteralPath $crewMaintenanceRequestsDir -Filter '*.json' -File -ErrorAction SilentlyContinue
781
+ foreach ($file in $files) {
782
+ try {
783
+ $request = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -ErrorAction Stop
784
+ } catch {
785
+ Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue
786
+ continue
787
+ }
788
+ if ($request.schema_version -ne 1 -or -not $request.request_id -or -not $request.operation) {
789
+ Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue
790
+ continue
791
+ }
792
+ if (($request.expires_at -as [long]) -lt $now) {
793
+ Write-MaintenanceResult $request 'MAINTENANCE_EXPIRED'
794
+ Write-LaunchLog ('Maintenance request {0} ({1}) expired; not executed.' -f $request.request_id, $request.operation) 'WARN'
795
+ continue
796
+ }
797
+ $crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
798
+ if (-not $crew) { continue }
799
+ if ($request.operation -eq 'maintenance-stop') {
800
+ # Authority: the request must name the SAME live runtime identity we own.
801
+ $liveRuntimeId = $null
802
+ try {
803
+ $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
804
+ $runtime = $resp.extension.runtime
805
+ $canonical = $resp.ok -eq $true `
806
+ -and $runtime.service -eq 'dsh-crew-hub' `
807
+ -and $runtime.execution_plane -eq 'hub-3210' `
808
+ -and $runtime.profile -eq 'dsh-crew' `
809
+ -and ($runtime.listen_port -as [int]) -eq 3210 `
810
+ -and ($runtime.protocol_version -as [int]) -eq 1
811
+ if ($canonical) { $liveRuntimeId = $runtime.runtime_id }
812
+ } catch { $liveRuntimeId = $null }
813
+ if (-not $liveRuntimeId -or $liveRuntimeId -ne $request.runtime_id) {
814
+ Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
815
+ Write-LaunchLog ('Maintenance-stop {0} rejected: live runtime_id mismatch.' -f $request.request_id) 'WARN'
816
+ continue
817
+ }
818
+ $port = Get-PortState $crew.Port
819
+ if ($port.State -ne 'occupied' -or -not $port.Pid) {
820
+ Write-MaintenanceResult $request 'SUPERVISOR_STOP_FAILED'
821
+ continue
822
+ }
823
+ $stopped = Stop-OwnedListener -Service $crew -ListenerPid ([int] $port.Pid)
824
+ if ($stopped) {
825
+ # The stopped window now belongs to the npx lifecycle: persist the
826
+ # STOPPED session (lease + proven runtime_id) so ordinary
827
+ # supervision will NOT auto-start 3210 until the matching start.
828
+ # STRONG semantics: STOPPED is published ONLY after the session is
829
+ # durably written AND read back with exact identity. A session write
830
+ # failure must never tell npx it owns a stopped window it cannot
831
+ # later prove (that race auto-restarts 3210 mid tree-swap).
832
+ $sessionDurable = Set-MaintenanceSession $request
833
+ if ($sessionDurable) {
834
+ Write-MaintenanceResult $request 'STOPPED' @{ lease = $request.lease; stopped_runtime_id = $request.runtime_id }
835
+ Write-LaunchLog ('Maintenance-stop {0} executed; lease issued.' -f $request.request_id)
836
+ } else {
837
+ Write-MaintenanceResult $request 'SUPERVISOR_SESSION_PERSIST_FAILED'
838
+ Write-LaunchLog ('Maintenance-stop {0} stopped the process but the STOPPED session could not be persisted; NOT publishing STOPPED.' -f $request.request_id) 'ERROR'
839
+ }
840
+ } else {
841
+ Write-MaintenanceResult $request 'SUPERVISOR_STOP_FAILED'
842
+ }
843
+ } elseif ($request.operation -eq 'maintenance-start') {
844
+ # Pair with the matching STOPPED session: the start request must carry
845
+ # the SAME lease and runtime_id the stop proved. A missing session,
846
+ # mismatched lease/identity, or a still-listening port all fail closed
847
+ # WITHOUT starting anything. The session is consumed on VERIFIED so
848
+ # the lease is one-shot (no replay).
849
+ $lease = $request.lease
850
+ $expectedCrew = $null
851
+ $expectedDsh = $null
852
+ if ($request.extra) {
853
+ $expectedCrew = $request.extra.expected_crew_version
854
+ $expectedDsh = $request.extra.expected_dsh_version
855
+ }
856
+ $session = Read-MaintenanceSession
857
+ if ($session -is [string] -or $session.lease -ne $lease -or $session.runtime_id -ne $request.runtime_id) {
858
+ Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
859
+ Write-LaunchLog ('Maintenance-start {0} rejected: no matching STOPPED session (lease/identity mismatch).' -f $request.request_id) 'WARN'
860
+ continue
861
+ }
862
+ $livePort = Get-PortState $crew.Port
863
+ if ($livePort.State -ne 'free') {
864
+ # occupied AND unknown both fail closed: the stopped window is not
865
+ # provably clean, so no start authority.
866
+ Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
867
+ Write-LaunchLog ('Maintenance-start {0} rejected: port {1} is not provably free (state={2}).' -f $request.request_id, $crew.Port, $livePort.State) 'WARN'
868
+ continue
869
+ }
870
+ Start-CrewService $crew
871
+ Wait-CrewServices
872
+ $newRuntime = $null
873
+ try {
874
+ $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 5
875
+ if ($resp.ok -eq $true) { $newRuntime = $resp.extension.runtime }
876
+ } catch { $newRuntime = $null }
877
+ $ok = $newRuntime -and $newRuntime.runtime_id `
878
+ -and $newRuntime.service -eq 'dsh-crew-hub' `
879
+ -and $newRuntime.execution_plane -eq 'hub-3210' `
880
+ -and $newRuntime.profile -eq 'dsh-crew' `
881
+ -and ($newRuntime.listen_port -as [int]) -eq 3210 `
882
+ -and ($newRuntime.protocol_version -as [int]) -eq 1 `
883
+ -and ($expectedCrew -eq $null -or $newRuntime.runtime_version -eq $expectedCrew) `
884
+ -and ($expectedDsh -eq $null -or $newRuntime.dsh_version -eq $expectedDsh)
885
+ if ($ok) {
886
+ # Verifiable one-shot consumption: only a provably-cleared session
887
+ # completes the transaction; otherwise the lease stays live and any
888
+ # replay is still fenced (no VERIFIED without consumption proof).
889
+ $consumed = Clear-MaintenanceSession
890
+ if ($consumed) {
891
+ Write-MaintenanceResult $request 'VERIFIED' @{ lease = $lease; runtime_id = $newRuntime.runtime_id; runtime_version = $newRuntime.runtime_version; dsh_version = $newRuntime.dsh_version }
892
+ Write-LaunchLog ('Maintenance-start {0} verified: Crew {1} + DSH {2}.' -f $request.request_id, $newRuntime.runtime_version, $newRuntime.dsh_version)
893
+ } else {
894
+ Write-MaintenanceResult $request 'VERIFY_FAILED' @{ lease = $lease; runtime_id = $newRuntime.runtime_id }
895
+ Write-LaunchLog ('Maintenance-start {0} identity verified but session consumption unproven; NOT marking VERIFIED.' -f $request.request_id) 'ERROR'
896
+ }
897
+ } else {
898
+ $failedRuntimeId = $null
899
+ if ($newRuntime) { $failedRuntimeId = $newRuntime.runtime_id }
900
+ Write-MaintenanceResult $request 'VERIFY_FAILED' @{ lease = $lease; runtime_id = $failedRuntimeId }
901
+ Write-LaunchLog ('Maintenance-start {0} verification failed.' -f $request.request_id) 'ERROR'
902
+ }
903
+ } else {
904
+ # Unknown maintenance op: remove and report.
905
+ Write-MaintenanceResult $request 'MAINTENANCE_UNKNOWN_OP'
906
+ }
907
+ }
908
+ }
909
+
910
+
911
+ function Get-PortState {
912
+ param([int] $Port)
913
+ try {
914
+ $listeners = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners()
915
+ $occupied = @($listeners | Where-Object Port -eq $Port).Count -gt 0
916
+ if (-not $occupied) {
917
+ return [pscustomobject]@{ State = 'free'; Error = $null; Pid = $null }
918
+ }
919
+
920
+ $ownerPid = $null
921
+ try {
922
+ $ownerPid = (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop | Select-Object -First 1).OwningProcess
923
+ } catch { }
924
+ return [pscustomobject]@{ State = 'occupied'; Error = $null; Pid = $ownerPid }
925
+ } catch {
926
+ return [pscustomobject]@{ State = 'unknown'; Error = ('Listener enumeration failed: {0}' -f $_.Exception.Message); Pid = $null }
927
+ }
928
+ }
929
+
930
+ function Test-TrackedProcessIdentity {
931
+ param([int] $ProcessId, [long] $ExpectedStartTicks, [object[]] $ProcessTable)
932
+ if ($ProcessId -lt 1 -or $ExpectedStartTicks -lt 1) { return $false }
933
+ $record = @($ProcessTable | Where-Object { [int] $_.ProcessId -eq $ProcessId } | Select-Object -First 1)
934
+ if ($record.Count -eq 0) { return $false }
935
+ $startTicks = $null
936
+ if ($record[0].PSObject.Properties.Name -contains 'StartTicks') {
937
+ $startTicks = [long] $record[0].StartTicks
938
+ } else {
939
+ try { $startTicks = (Get-Process -Id $ProcessId -ErrorAction Stop).StartTime.ToUniversalTime().Ticks } catch { return $false }
940
+ }
941
+ return $startTicks -eq $ExpectedStartTicks
942
+ }
943
+
944
+ function Get-TrackedProcessTree {
945
+ param([pscustomobject] $Service, [object[]] $ProcessTable = $null)
946
+ $processes = if ($null -ne $ProcessTable) { @($ProcessTable) } else { @(Get-CimInstance Win32_Process -ErrorAction Stop) }
947
+ $owned = [System.Collections.Generic.HashSet[int]]::new()
948
+ $rootMatches = Test-TrackedProcessIdentity -ProcessId $Service.RootPid -ExpectedStartTicks $Service.RootStartedAtUtcTicks -ProcessTable $processes
949
+ $hasTrackedListener = $Service.ListenerPid -and $Service.ListenerStartedAtUtcTicks
950
+ if ($hasTrackedListener) {
951
+ $listenerMatches = Test-TrackedProcessIdentity -ProcessId $Service.ListenerPid -ExpectedStartTicks $Service.ListenerStartedAtUtcTicks -ProcessTable $processes
952
+ if (-not $listenerMatches) { return @() }
953
+ [void] $owned.Add([int] $Service.ListenerPid)
954
+ } elseif (-not $rootMatches) {
955
+ return @()
956
+ }
957
+ if ($rootMatches) { [void] $owned.Add([int] $Service.RootPid) }
958
+ do {
959
+ $added = $false
960
+ foreach ($candidate in $processes) {
961
+ $candidateId = [int] $candidate.ProcessId
962
+ $parentId = [int] $candidate.ParentProcessId
963
+ if ($owned.Contains($parentId) -and $owned.Add($candidateId)) { $added = $true }
964
+ }
965
+ } while ($added)
966
+ return @($owned | ForEach-Object { [int] $_ })
967
+ }
968
+
969
+ function Set-TrackedListenerIdentity {
970
+ param([pscustomobject] $Service)
971
+ $port = Get-PortState $Service.Port
972
+ if ($port.State -ne 'occupied' -or -not $port.Pid) { return $false }
973
+ $tree = @(Get-TrackedProcessTree -Service $Service)
974
+ if ($port.Pid -notin $tree) { return $false }
975
+ try {
976
+ $listener = Get-Process -Id $port.Pid -ErrorAction Stop
977
+ $Service.ListenerPid = [int] $port.Pid
978
+ $Service.ListenerStartedAtUtcTicks = $listener.StartTime.ToUniversalTime().Ticks
979
+ return $true
980
+ } catch {
981
+ return $false
982
+ }
983
+ }
984
+
985
+ function Test-CrewServiceOwnership {
986
+ param(
987
+ [pscustomObject] $Service,
988
+ [pscustomObject] $PortState = $null,
989
+ [object[]] $ProcessTable = $null
990
+ )
991
+ if (-not $Service.ListenerPid -or -not $Service.ListenerStartedAtUtcTicks) { return $false }
992
+ $port = if ($null -ne $PortState) { $PortState } else { Get-PortState $Service.Port }
993
+ if ($port.State -ne 'occupied' -or -not $port.Pid -or [int] $port.Pid -ne [int] $Service.ListenerPid) { return $false }
994
+ try {
995
+ $tree = if ($null -ne $ProcessTable) {
996
+ @(Get-TrackedProcessTree -Service $Service -ProcessTable $ProcessTable)
997
+ } else {
998
+ @(Get-TrackedProcessTree -Service $Service)
999
+ }
1000
+ return ([int] $port.Pid -in $tree)
1001
+ } catch {
1002
+ return $false
1003
+ }
1004
+ }
1005
+
1006
+ function Stop-OwnedListener {
1007
+ param([pscustomobject] $Service, [int] $ListenerPid)
1008
+ if ($Mode -ne 'watch' -or -not $Service.ListenerPid -or $ListenerPid -ne $Service.ListenerPid) {
1009
+ return $false
1010
+ }
1011
+ try {
1012
+ $ownedProcessIds = @(Get-TrackedProcessTree -Service $Service)
1013
+ if ($ListenerPid -notin $ownedProcessIds) { return $false }
1014
+ Write-LaunchLog ('Supervisor confirmed owned listener PID={0} under tracked root PID={1}; stopping that process tree after {2} consecutive failed health checks.' -f $ListenerPid, $Service.RootPid, $Service.ConsecutiveFailures) 'WARN'
1015
+ foreach ($processId in $ownedProcessIds) {
1016
+ Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
1017
+ }
1018
+ $deadline = (Get-Date).AddSeconds(5)
1019
+ do {
1020
+ $port = Get-PortState $Service.Port
1021
+ if ($port.State -eq 'free') { return $true }
1022
+ Start-Sleep -Milliseconds 250
1023
+ } while ((Get-Date) -lt $deadline)
1024
+ } catch {
1025
+ Write-LaunchLog ('Supervisor could not stop its owned listener safely: {0}' -f $_.Exception.Message) 'WARN'
1026
+ }
1027
+ return $false
1028
+ }
1029
+
1030
+ function Assert-HistoryStartAllowed {
1031
+ param([string] $StatePath = (Join-Path $crewHome '..\history\active.json'))
1032
+ if (-not (Test-Path -LiteralPath $StatePath)) { return }
1033
+ try {
1034
+ $state = Get-Content -LiteralPath $StatePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
1035
+ if ($state.schemaVersion -eq 1 -and $state.phase -in @('STARTING', 'VERIFYING', 'DONE', 'FAILED', 'ROLLED_BACK')) { return }
1036
+ } catch { }
1037
+ throw 'HISTORY_RECOVERY_REQUIRED: Crew history maintenance is unfinished. Run dsh-crew history recover. Official 3080 is unchanged.'
1038
+ }
1039
+
1040
+ function Start-CrewService {
1041
+ param([pscustomobject] $Service)
1042
+ Assert-HistoryStartAllowed
1043
+ $serviceRunStamp = (Get-Date).ToString('yyyyMMdd-HHmmssfff')
1044
+ $stdout = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.out.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
1045
+ $stderr = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.err.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
1046
+ $previousHome = $env:DSH_HOME
1047
+ try {
1048
+ $env:DSH_HOME = $Service.Home
1049
+ $arguments = @('--profile', $Service.Profile, '--host', '127.0.0.1', '--port', [string] $Service.Port, '--no-open')
1050
+ $launchArguments = if ($dshCliIsNodeEntry) { @($dshCli) + $arguments } else { $arguments }
1051
+ $process = Start-Process -FilePath $dshCommand -ArgumentList $launchArguments -WindowStyle Hidden -PassThru `
1052
+ -RedirectStandardOutput $stdout -RedirectStandardError $stderr
1053
+ $Service.Process = $process
1054
+ $Service.RootPid = $process.Id
1055
+ $Service.RootStartedAtUtcTicks = $process.StartTime.ToUniversalTime().Ticks
1056
+ $Service.ListenerPid = $null
1057
+ $Service.ListenerStartedAtUtcTicks = $null
1058
+ $Service.ConsecutiveFailures = 0
1059
+ $Service.State = 'starting'
1060
+ Write-LaunchLog ('Started {0} on port {1}; PID={2}; stdout={3}; stderr={4}' -f $Service.Profile, $Service.Port, $process.Id, $stdout, $stderr)
1061
+ } finally {
1062
+ $env:DSH_HOME = $previousHome
1063
+ }
1064
+ }
1065
+
1066
+ function Wait-CrewServices {
1067
+ $deadline = (Get-Date).AddSeconds(90)
1068
+ while (@($services | Where-Object State -eq 'starting').Count -gt 0 -and (Get-Date) -lt $deadline) {
1069
+ foreach ($service in ($services | Where-Object State -eq 'starting')) {
1070
+ $health = Get-HealthState $service
1071
+ $service.LastError = $health.Error
1072
+ if ($health.Ready) {
1073
+ if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
1074
+ throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
1075
+ }
1076
+ if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
1077
+ throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
1078
+ }
1079
+ $service.State = 'ready'
1080
+ $service.ConsecutiveFailures = 0
1081
+ Write-LaunchLog ('{0} ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
1082
+ } elseif ($service.Process -and $service.Process.HasExited) {
1083
+ throw ('{0} exited before becoming ready; PID={1}; exit={2}; last health error: {3}' -f $service.Name, $service.Process.Id, $service.Process.ExitCode, $health.Error)
1084
+ }
1085
+ }
1086
+ if (@($services | Where-Object State -eq 'starting').Count -gt 0) { Start-Sleep -Milliseconds 500 }
1087
+ }
1088
+
1089
+ $notReady = @($services | Where-Object {
1090
+ $_.State -ne 'ready'
1091
+ })
1092
+ if ($notReady.Count -gt 0) {
1093
+ $details = ($notReady | ForEach-Object { '{0}:{1} ({2})' -f $_.Profile, $_.Port, $_.LastError }) -join '; '
1094
+ throw "Startup health deadline exceeded: $details"
1095
+ }
1096
+ }
1097
+
1098
+ # Explicit diagnostic helper; not called by normal startup or supervision.
1099
+ function Write-LegacyBridgeDiagnostic {
1100
+ if (Test-LegacyBridgeAvailable) {
1101
+ Write-LaunchLog 'Diagnostic: legacy 3080 bridge still answers ping; Crew launcher owns 3210 directly, bridge ignored.'
1102
+ }
1103
+ }
1104
+
1105
+ function Ensure-CrewServices {
1106
+ param([switch] $QuietHealthy)
1107
+
1108
+ foreach ($service in $services) {
1109
+ # Maintenance fence: while a STOPPED maintenance session is active for
1110
+ # the Crew backend, ordinary supervision must NOT auto-start 3210 —
1111
+ # the stopped window belongs to the npx lifecycle's tree swap. Only a
1112
+ # matching maintenance-start owns the launch right.
1113
+ if ($service.CrewOwned -and (Test-MaintenanceSessionActive)) {
1114
+ $service.State = 'maintenance'
1115
+ $service.LastError = $null
1116
+ continue
1117
+ }
1118
+ $health = Get-HealthState $service
1119
+ if ($health.Ready) {
1120
+ $wasReady = $service.State -eq 'ready'
1121
+ if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
1122
+ throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
1123
+ }
1124
+ if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
1125
+ throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
1126
+ }
1127
+ $service.State = 'ready'
1128
+ $service.ConsecutiveFailures = 0
1129
+ $service.LastError = $null
1130
+ if (-not $QuietHealthy -or -not $wasReady) {
1131
+ Write-LaunchLog ('{0} already ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
1132
+ }
1133
+ continue
1134
+ }
1135
+
1136
+ # The Crew launcher owns 3210 directly and always boots it below.
1137
+ # Legacy bridge presence is diagnosed only after 3210 is ready (see
1138
+ # Wait-CrewServices tail), never on the boot path.
1139
+
1140
+ $service.LastError = $health.Error
1141
+ $service.ConsecutiveFailures += 1
1142
+ if ($service.Process -and $service.Process.HasExited) {
1143
+ Write-LaunchLog ('{0} process exited after startup; PID={1}; exit={2}' -f $service.Name, $service.Process.Id, $service.Process.ExitCode) 'WARN'
1144
+ $service.Process = $null
1145
+ }
1146
+ $service.State = 'pending'
1147
+
1148
+ $port = Get-PortState $service.Port
1149
+ if ($port.State -eq 'occupied') {
1150
+ if ($service.ConsecutiveFailures -lt 3) {
1151
+ throw ('Health check {0}/3 failed for {1}; owned process remains untouched until failure is confirmed. Health error: {2}' -f $service.ConsecutiveFailures, $service.Name, $health.Error)
1152
+ }
1153
+ if (Stop-OwnedListener -Service $service -ListenerPid $port.Pid) {
1154
+ $service.Process = $null
1155
+ $service.RootPid = $null
1156
+ $service.RootStartedAtUtcTicks = $null
1157
+ $service.ListenerPid = $null
1158
+ $service.ListenerStartedAtUtcTicks = $null
1159
+ $service.ConsecutiveFailures = 0
1160
+ $port = Get-PortState $service.Port
1161
+ }
1162
+ }
1163
+ if ($port.State -eq 'occupied') {
1164
+ $owner = if ($port.Pid) { "; listener PID=$($port.Pid)" } else { '' }
1165
+ throw ('Port {0} is occupied, but {1} failed its health contract{2}. Health error: {3}' -f $service.Port, $service.Name, $owner, $health.Error)
1166
+ }
1167
+ if ($port.State -ne 'free') {
1168
+ throw ('Could not determine whether port {0} is available: {1}' -f $service.Port, $port.Error)
1169
+ }
1170
+ if ($Mode -eq 'watch') {
1171
+ Write-LaunchLog ('Supervisor detected {0} unavailable on {1}; restarting it.' -f $service.Name, $service.Port) 'WARN'
1172
+ }
1173
+ Start-CrewService $service
1174
+ }
1175
+
1176
+ Wait-CrewServices
1177
+ }
1178
+
1179
+ function Start-ServiceSupervisor {
1180
+ $mutex = [System.Threading.Mutex]::new($false, 'Local\DSHCrewServiceSupervisor')
1181
+ $ownsMutex = $false
1182
+ try {
1183
+ try {
1184
+ $ownsMutex = $mutex.WaitOne(0)
1185
+ } catch [System.Threading.AbandonedMutexException] {
1186
+ $ownsMutex = $true
1187
+ }
1188
+ if (-not $ownsMutex) {
1189
+ Write-LaunchLog 'Supervisor already active; duplicate watcher exiting.'
1190
+ return
1191
+ }
1192
+
1193
+ # Publish process identity before any 3210 startup/health wait. Handoff
1194
+ # callers can now distinguish a legitimate cold-starting target watcher
1195
+ # from the stale heartbeat left by the watcher it replaced.
1196
+ Write-SupervisorHeartbeat -OwnershipReady $false
1197
+ Write-LaunchLog 'Supervisor active; monitoring Crew-owned 3210 every 10 seconds.'
1198
+ $lastRecoveryError = $null
1199
+ $updateLockFile = Join-Path $crewHome '..\app\update-in-progress.lock'
1200
+ $staleLockNotified = $false
1201
+ while ($true) {
1202
+ try {
1203
+ # An installer-held update lock suspends recovery restarts: the tree
1204
+ # under Crew-owned state may be mid-migration, and a restart now
1205
+ # would boot a half-installed runtime/profile. A lock whose owner
1206
+ # process is dead is stale: log once and resume supervision instead
1207
+ # of staying observe-only forever.
1208
+ $updateHeld = $false
1209
+ if (Test-Path -LiteralPath $updateLockFile -PathType Leaf) {
1210
+ $lockAlive = $false
1211
+ try {
1212
+ $lockRecord = Get-Content -LiteralPath $updateLockFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
1213
+ $lockPid = [int] $lockRecord.pid
1214
+ if ($lockPid -gt 0) {
1215
+ Get-Process -Id $lockPid -ErrorAction Stop | Out-Null
1216
+ $lockAlive = $true
1217
+ }
1218
+ } catch { $lockAlive = $false }
1219
+ if ($lockAlive) {
1220
+ $updateHeld = $true
1221
+ $staleLockNotified = $false
1222
+ } elseif (-not $staleLockNotified) {
1223
+ Write-LaunchLog 'Stale update lock detected (owner process dead); resuming supervision. Re-run install/update to reconcile.' 'WARN'
1224
+ $staleLockNotified = $true
1225
+ }
1226
+ } else {
1227
+ $staleLockNotified = $false
1228
+ }
1229
+ # Maintenance stop/start is the updater's owned process handoff and
1230
+ # must remain live while the update lock fences ordinary recovery.
1231
+ Invoke-CrewMaintenanceRequests
1232
+ if ($updateHeld) {
1233
+ Write-LaunchLog 'Update in progress; maintenance only, ordinary recovery suspended.'
1234
+ $lastRecoveryError = $null
1235
+ } else {
1236
+ # Control protocol: publish this watcher's heartbeat and consume
1237
+ # any durable restart requests the hub wrote (3210 never spawns
1238
+ # itself). Maintenance was consumed above; now run the ordinary
1239
+ # health pass.
1240
+ Invoke-CrewRestartRequests
1241
+ Ensure-CrewServices -QuietHealthy
1242
+ if ($lastRecoveryError) {
1243
+ Write-LaunchLog 'Supervisor recovery succeeded; Crew-owned 3210 is healthy.'
1244
+ $lastRecoveryError = $null
1245
+ }
1246
+ }
1247
+ $ownedServices = @($services | Where-Object { $_.CrewOwned -and (Test-CrewServiceOwnership -Service $_) })
1248
+ $expectedServices = @($services | Where-Object { $_.CrewOwned })
1249
+ Write-SupervisorHeartbeat -OwnershipReady ($expectedServices.Count -gt 0 -and $ownedServices.Count -eq $expectedServices.Count)
1250
+ } catch {
1251
+ $recoveryError = $_.Exception.Message
1252
+ Write-SupervisorHeartbeat -OwnershipReady $false
1253
+ if ($recoveryError -ne $lastRecoveryError) {
1254
+ Write-LaunchLog ('Supervisor recovery failed; will retry: {0}' -f $recoveryError) 'WARN'
1255
+ $lastRecoveryError = $recoveryError
1256
+ }
1257
+ }
1258
+ Start-Sleep -Seconds 10
1259
+ }
1260
+ } finally {
1261
+ if ($ownsMutex) { $mutex.ReleaseMutex() }
1262
+ $mutex.Dispose()
1263
+ }
1264
+ }
1265
+
1266
+ if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { return }
1267
+
1268
+ try {
1269
+ New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
1270
+ Write-LaunchLog ('Launcher started; mode={0}; user={1}' -f $Mode, $env:USERNAME)
1271
+ if (-not (Test-Path -LiteralPath $dshCli -PathType Leaf)) {
1272
+ throw "DSH CLI was not found at $dshCli. Configure DSH_CREW_DSH_CLI to a Crew-owned official CLI entry, or run: npm install -g @ran-sh/dsh-crew@latest; dsh-crew update"
1273
+ }
1274
+ Test-CrewDshCliPreflight
1275
+ if (-not (Test-Path -LiteralPath (Join-Path $crewHome 'profiles\dsh-crew\package.json') -PathType Leaf)) {
1276
+ throw "The isolated dsh-crew profile is missing under $crewHome. Run: dsh-crew update"
1277
+ }
1278
+ if ($Mode -eq 'open') { Open-OfficialFrontend }
1279
+ # Background/watch modes remain independent of the official frontend.
1280
+
1281
+ if ($Mode -eq 'watch') {
1282
+ Start-ServiceSupervisor
1283
+ Write-LaunchLog 'Supervisor stopped.' 'WARN'
1284
+ exit 0
1285
+ }
1286
+
1287
+ Ensure-CrewSupervisorRunning
1288
+
1289
+ if ($Mode -eq 'open') {
1290
+ Write-LaunchLog 'Official frontend is on 3080; Crew is running silently on 3210.'
1291
+ }
1292
+ Write-LaunchLog ('Launcher completed successfully in {0:n1}s.' -f ((Get-Date) - $startedAt).TotalSeconds)
1293
+ exit 0
1294
+ } catch {
1295
+ Write-LaunchLog $_.Exception.Message 'ERROR'
1296
+ if ($Mode -eq 'open') {
1297
+ Write-Host ''
1298
+ Write-Host "Diagnostic log: $launcherLog" -ForegroundColor Yellow
1299
+ Write-Host 'Startup failed. Review the diagnostic log and the service stdout/stderr paths recorded above.' -ForegroundColor Yellow
1300
+ }
1301
+ exit 1
1302
+ }