@ran-sh/dsh-crew 1.1.0 → 1.2.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.claude-plugin/marketplace.json +16 -17
  2. package/.claude-plugin/plugin.json +7 -1
  3. package/README.md +44 -15
  4. package/README.zh.md +37 -12
  5. package/agents/ds-flash.md +16 -18
  6. package/agents/ds-pro.md +16 -24
  7. package/agents/ds-reviewer.md +15 -15
  8. package/agents/ds-worker.md +16 -16
  9. package/bin/dsh-crew.mjs +16 -12
  10. package/codex/AGENTS.md +37 -85
  11. package/codex/agents/ds-flash.toml +18 -14
  12. package/codex/agents/ds-pro.toml +18 -15
  13. package/codex/agents/ds-reviewer.toml +17 -13
  14. package/codex/agents/ds-worker.toml +17 -13
  15. package/codex/prompts/dsh-config.md +17 -1
  16. package/codex/prompts/dsh-status.md +5 -1
  17. package/commands/config.md +16 -4
  18. package/commands/status.md +6 -2
  19. package/docs/installation.md +54 -9
  20. package/docs/readiness-matrix.md +14 -6
  21. package/docs/ui-surfaces.md +32 -17
  22. package/lib/client.js +5434 -4844
  23. package/official-web-bridge/lib/client.js +567 -479
  24. package/official-web-bridge/overlay-entry.mjs +59 -0
  25. package/package.json +6 -3
  26. package/scripts/build-client.mjs +2 -0
  27. package/scripts/setup.mjs +8 -7
  28. package/scripts/verify-crew-ui-polish.mjs +145 -0
  29. package/scripts/verify-history-ui.mjs +97 -0
  30. package/scripts/verify-npm-install.mjs +1 -1
  31. package/src/client/history-panel.tsx +108 -0
  32. package/src/client/host-readiness.mjs +15 -6
  33. package/src/client/index.tsx +86 -64
  34. package/src/client/model-callability-view.mjs +17 -0
  35. package/src/client/panel-chrome.tsx +40 -0
  36. package/src/client/quick-panel.tsx +88 -73
  37. package/src/client/readiness-envelope.mjs +34 -0
  38. package/src/config-readiness.mjs +181 -115
  39. package/src/extension-contract.mjs +44 -26
  40. package/src/history/admission-gate.mjs +51 -0
  41. package/src/history/archive-store.mjs +261 -0
  42. package/src/history/cleanup-plan.mjs +64 -0
  43. package/src/history/http.mjs +32 -0
  44. package/src/history/operation.mjs +86 -0
  45. package/src/history/runner.mjs +51 -0
  46. package/src/history/runtime.mjs +28 -0
  47. package/src/history/service.mjs +137 -0
  48. package/src/history/state.mjs +27 -0
  49. package/src/hub/index.mjs +141 -24
  50. package/src/hub-client.mjs +17 -15
  51. package/src/image-output.mjs +71 -0
  52. package/src/install/install-legacy.mjs +126 -23
  53. package/src/install/npx-lifecycle.mjs +373 -64
  54. package/src/install/official-frontend-assets.mjs +78 -0
  55. package/src/install/payload-content.mjs +88 -0
  56. package/src/install/windows-startup.mjs +107 -19
  57. package/src/install/windows-supervisor-adapter.mjs +443 -0
  58. package/src/install/windows-supervisor-lifecycle.mjs +782 -0
  59. package/src/install/zcode.mjs +53 -14
  60. package/src/job-contracts.mjs +37 -31
  61. package/src/jobs.mjs +2 -2
  62. package/src/mcp-runtime.mjs +74 -9
  63. package/src/model-callability-contract.mjs +79 -0
  64. package/src/multimodal.mjs +0 -0
  65. package/src/official-web-bridge.mjs +0 -2
  66. package/src/provider-health.mjs +130 -124
  67. package/src/provider-profile-store.mjs +12 -0
  68. package/src/provider-settings-store.mjs +347 -5
  69. package/src/readiness-matrix.mjs +20 -15
  70. package/src/runtime-identity-contract.mjs +34 -0
  71. package/src/runtime-identity.mjs +14 -4
  72. package/src/runtime-readiness-snapshot.mjs +285 -87
  73. package/src/server.mjs +86 -76
  74. package/src/standalone-sdk.mjs +23 -0
  75. package/src/supervisor/restart-request.mjs +64 -10
  76. package/src/workflow-runtime.mjs +28 -13
  77. package/windows/start-dsh-crew.cmd +57 -57
  78. package/windows/start-dsh-crew.ps1 +327 -55
  79. package/windows/supervisor-control.ps1 +467 -0
  80. package/zcode/AGENTS.md +4 -1
  81. package/zcode/agents/ds-reviewer.md +31 -36
  82. package/zcode/agents/ds-worker.md +31 -39
  83. package/zcode/commands/dsh-config.md +17 -6
  84. package/zcode/commands/dsh-status.md +5 -4
  85. package/.mcp.json +0 -8
@@ -9,13 +9,22 @@ Set-StrictMode -Version Latest
9
9
  $ErrorActionPreference = 'Stop'
10
10
 
11
11
  $crewHome = Join-Path $env:USERPROFILE '.config\dsh-crew\harness'
12
- $officialHome = Join-Path $env:USERPROFILE '.dsh'
13
12
  $dshCli = Join-Path $crewHome 'runtime\node_modules\.bin\dsh.cmd'
14
13
  $logRoot = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
15
14
  $launcherLog = Join-Path $logRoot 'dsh-crew-launcher.log'
16
15
  $startedAt = Get-Date
16
+ $launcherProcessStartedAtUtcTicks = try { (Get-Process -Id $PID -ErrorAction Stop).StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture) } catch { $null }
17
+ $supervisorInstanceId = [guid]::NewGuid().ToString()
18
+ $launcherHelperHash = $null
19
+ try {
20
+ $hashStream = [System.IO.File]::OpenRead($PSCommandPath)
21
+ try {
22
+ $sha256 = [System.Security.Cryptography.SHA256]::Create()
23
+ try { $launcherHelperHash = (($sha256.ComputeHash($hashStream) | ForEach-Object { $_.ToString('x2') }) -join '') }
24
+ finally { $sha256.Dispose() }
25
+ } finally { $hashStream.Dispose() }
26
+ } catch { $launcherHelperHash = $null }
17
27
  $services = @(
18
- [pscustomobject]@{ Name = 'Official UI'; Profile = 'web'; Home = $officialHome; Port = 3080; Url = 'http://127.0.0.1:3080'; CrewOwned = $false; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null },
19
28
  [pscustomobject]@{ Name = 'Crew backend'; Profile = 'dsh-crew'; Home = $crewHome; 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 }
20
29
  )
21
30
 
@@ -30,11 +39,136 @@ function Write-LaunchLog {
30
39
  }
31
40
  }
32
41
 
42
+ function Resolve-OfficialHarnessCommand {
43
+ $shim = Get-Command dsh.cmd -CommandType Application -ErrorAction Stop | Select-Object -First 1
44
+ $root = Join-Path (Split-Path -Parent $shim.Source) 'node_modules\@deepseek-ai\dsh'
45
+ $manifest = Get-Content -LiteralPath (Join-Path $root 'package.json') -Raw | ConvertFrom-Json
46
+ if ($manifest.name -ne '@deepseek-ai/dsh') { throw 'The dsh command is not a verified official Harness installation.' }
47
+ $entrySpec = [string] $manifest.bin.dsh
48
+ if (-not $entrySpec -or [IO.Path]::IsPathRooted($entrySpec) -or $entrySpec.Contains(':') -or ($entrySpec.Replace('\', '/').Split('/') -contains '..')) {
49
+ throw 'The official Harness CLI entry is invalid.'
50
+ }
51
+ $entry = [IO.Path]::GetFullPath((Join-Path $root $entrySpec))
52
+ $rootPrefix = [IO.Path]::GetFullPath($root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
53
+ if (-not $entry.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase) -or -not (Test-Path -LiteralPath $entry -PathType Leaf)) {
54
+ throw 'The official Harness CLI entry was not found.'
55
+ }
56
+ $node = Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1
57
+ return [pscustomobject]@{ NodePath = $node.Source; Entry = $entry }
58
+ }
59
+
60
+ function Test-OfficialHarnessListener {
61
+ param([int] $OwnerPid, [pscustomobject] $Official)
62
+ if ($OwnerPid -le 0) { return $false }
63
+ try {
64
+ $owner = Get-CimInstance Win32_Process -Filter ('ProcessId={0}' -f $OwnerPid) -ErrorAction Stop
65
+ if (-not $owner -or [IO.Path]::GetFileName([string] $owner.ExecutablePath) -ine 'node.exe') { return $false }
66
+ $line = ([string] $owner.CommandLine).Replace('/', '\')
67
+ $entry = [regex]::Escape($Official.Entry.Replace('/', '\'))
68
+ $node = [regex]::Escape($Official.NodePath.Replace('/', '\'))
69
+ $pattern = '^\s*(?:"?' + $node + '"?|"?node(?:\.exe)?"?)\s+"?' + $entry + '"?\s+(?:"?web"?|--profile\s+"?web"?)(?:\s|$)'
70
+ return $line -match $pattern
71
+ } catch { return $false }
72
+ }
73
+
74
+ function Test-OfficialWebReady {
75
+ try {
76
+ $response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:3080/' -TimeoutSec 2
77
+ return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300
78
+ } catch {
79
+ # An authenticated official UI may reject this cookie-free health request.
80
+ if ($_.Exception.Response) { return [int] $_.Exception.Response.StatusCode -in @(401, 403) }
81
+ return $false
82
+ }
83
+ }
84
+
85
+ function Get-OfficialFrontendOverlay {
86
+ $frontendRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\frontend'
87
+ $path = Join-Path $frontendRoot 'official-web.patch.json'
88
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'Crew frontend overlay is missing. Run dsh-crew update with the current GitHub candidate.' }
89
+ $patch = @(Get-Content -LiteralPath $path -Raw | ConvertFrom-Json)
90
+ if ($patch.Count -ne 1 -or @($patch[0].insert).Count -ne 1 -or $patch[0].insert[0].id -ne 'dsh-crew-official-web-bridge') {
91
+ throw 'Crew frontend overlay has an unexpected structure.'
92
+ }
93
+ $uri = [Uri] ([string] $patch[0].insert[0].name)
94
+ if (-not $uri.IsFile) { throw 'Crew frontend must load from its local snapshot.' }
95
+ $entry = [IO.Path]::GetFullPath($uri.LocalPath)
96
+ $prefix = [IO.Path]::GetFullPath((Join-Path $frontendRoot 'revisions')) + [IO.Path]::DirectorySeparatorChar
97
+ if (-not $entry.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Crew frontend snapshot is outside its owned directory.' }
98
+ $metadata = Get-Content -LiteralPath (Join-Path (Split-Path -Parent $entry) 'package.json') -Raw | ConvertFrom-Json
99
+ $revision = [string] $metadata.dshCrewFrontendRevision
100
+ if ($metadata.dshCrewManagedFrontend -ne $true -or $revision -notmatch '^[a-f0-9]{64}$' -or -not (Test-Path -LiteralPath $entry -PathType Leaf)) {
101
+ throw 'Crew frontend snapshot is incomplete.'
102
+ }
103
+ return [pscustomobject]@{ Path = $path; Revision = $revision }
104
+ }
105
+
106
+ function Test-OfficialFrontendAttached {
107
+ param([string] $Revision)
108
+ try {
109
+ $response = Invoke-RestMethod -Uri 'http://127.0.0.1:3080/_dsh/dsh-crew/bridge-status' -TimeoutSec 2
110
+ return $response.ok -eq $true -and $response.surface -eq 'official-bridge' -and $response.frontend_revision -eq $Revision
111
+ } catch { return $false }
112
+ }
113
+
114
+ function Open-OfficialFrontend {
115
+ param([int] $TimeoutSeconds = 90)
116
+ $official = Resolve-OfficialHarnessCommand
117
+ $frontend = Get-OfficialFrontendOverlay
118
+ $mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
119
+ $locked = $false
120
+ try {
121
+ try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
122
+ if (-not $locked) { throw 'Another desktop launch is still starting the official frontend.' }
123
+ $port = Get-PortState -Port 3080
124
+ if ($port.State -eq 'unknown') { throw $port.Error }
125
+ if ($port.State -eq 'occupied') {
126
+ if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 is occupied by an unverified process; it was left untouched.' }
127
+ if (-not (Test-OfficialWebReady)) { throw 'The official 3080 frontend is not ready; its process was left running.' }
128
+ 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.' }
129
+ Start-Process 'http://127.0.0.1:3080/' | Out-Null
130
+ Write-LaunchLog 'Opened the existing official Harness frontend on 3080.'
131
+ return
132
+ }
133
+
134
+ $stamp = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmssfff'), $PID
135
+ $stdout = Join-Path $logRoot ('dsh-official-web-{0}.out.log' -f $stamp)
136
+ $stderr = Join-Path $logRoot ('dsh-official-web-{0}.err.log' -f $stamp)
137
+ $previousHome = $env:DSH_HOME
138
+ try {
139
+ # The official application owns its normal state; no Crew plugin/profile
140
+ # registration or repair is performed in this home by the launcher.
141
+ $env:DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
142
+ $arguments = @(('"{0}"' -f $official.Entry), 'web', '--patch', ('"{0}"' -f $frontend.Path), '--host', '127.0.0.1', '--port', '3080')
143
+ $process = Start-Process -FilePath $official.NodePath -ArgumentList $arguments -WindowStyle Hidden -PassThru `
144
+ -RedirectStandardOutput $stdout -RedirectStandardError $stderr
145
+ } finally { $env:DSH_HOME = $previousHome }
146
+ Write-LaunchLog ('Started official Harness on 3080; PID={0}. The official CLI opens the browser. Logs: {1}, {2}' -f $process.Id, $stdout, $stderr)
147
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
148
+ $nextProgress = (Get-Date).AddSeconds(5)
149
+ do {
150
+ $port = Get-PortState -Port 3080
151
+ if ($port.State -eq 'occupied') {
152
+ if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 became occupied by an unverified process; it was left untouched.' }
153
+ if ((Test-OfficialWebReady) -and (Test-OfficialFrontendAttached -Revision $frontend.Revision)) { return }
154
+ }
155
+ if ($process.HasExited) { throw ('Official Harness exited before readiness. Diagnostic log: {0}' -f $stderr) }
156
+ if ((Get-Date) -ge $nextProgress) {
157
+ Write-LaunchLog 'Waiting for the official Harness frontend on 3080...'
158
+ $nextProgress = (Get-Date).AddSeconds(5)
159
+ }
160
+ Start-Sleep -Milliseconds 500
161
+ } while ((Get-Date) -lt $deadline)
162
+ throw ('Official Harness did not become ready within {0}s; inspect {1}. No official process was stopped.' -f $TimeoutSeconds, $stderr)
163
+ } finally {
164
+ if ($locked) { $mutex.ReleaseMutex() }
165
+ $mutex.Dispose()
166
+ }
167
+ }
168
+
33
169
  function Get-HealthState {
34
170
  param([pscustomObject] $Service)
35
- # Crew-owned 3210 answers the Crew extension contract. The official 3080
36
- # is an external dependency: probe only its native web root, never the
37
- # Crew bridge endpoint, so a missing legacy bridge cannot fail 3080 health.
171
+ # Preserve the installed extension health contract used by maintenance.
38
172
  if ($Service.CrewOwned) {
39
173
  try {
40
174
  $response = Invoke-RestMethod -Uri ($Service.Url + '/_dsh/dsh-crew/extension') -TimeoutSec 2
@@ -71,28 +205,10 @@ function Get-HealthState {
71
205
  return [pscustomobject]@{ Ready = $false; Version = $null; Error = $_.Exception.Message }
72
206
  }
73
207
  }
74
- try {
75
- # The official 3080 UI may require a token (HTTP 401/403 on the bare
76
- # root) or redirect to the web entry (3xx). Accept exactly those; any
77
- # other status (e.g. 404 from a foreign HTTP service squatting on 3080)
78
- # is NOT healthy.
79
- $response = Invoke-WebRequest -UseBasicParsing -Uri $Service.Url -TimeoutSec 2
80
- if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 400) {
81
- return [pscustomobject]@{ Ready = $true; Version = $null; Error = $null }
82
- }
83
- return [pscustomobject]@{ Ready = $false; Version = $null; Error = ('Official UI returned HTTP {0}.' -f $response.StatusCode) }
84
- } catch {
85
- # Invoke-WebRequest surfaces 401/403 as exceptions; those prove the web
86
- # service is alive and answering (token gate present).
87
- $status = $null
88
- if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
89
- $status = [int] $_.Exception.Response.StatusCode
90
- }
91
- if ($status -eq 401 -or $status -eq 403) {
92
- return [pscustomobject]@{ Ready = $true; Version = $null; Error = $null }
93
- }
94
- return [pscustomobject]@{ Ready = $false; Version = $null; Error = $_.Exception.Message }
95
- }
208
+ # The launcher supervises only the Crew-owned 3210 service. Any non-Crew
209
+ # service is never started or health-gated here; the legacy 3080 bridge
210
+ # is probed only diagnostically after 3210 readiness (see Write-LegacyBridgeDiagnostic).
211
+ return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'Unknown service: only the Crew-owned 3210 service is supervised.' }
96
212
  }
97
213
 
98
214
  # Optional legacy compatibility probe: returns true only when a 3080 that
@@ -117,17 +233,114 @@ $crewHeartbeatFile = Join-Path $crewSupervisorRoot 'heartbeat.json'
117
233
  $crewRequestsDir = Join-Path $crewSupervisorRoot 'restart-requests'
118
234
  $crewResultsDir = Join-Path $crewSupervisorRoot 'restart-results'
119
235
 
236
+ # UTF-8 WITHOUT BOM: Windows PowerShell 5.1's Set-Content -Encoding UTF8
237
+ # writes a BOM, and the Node hub's JSON.parse rejects it (heartbeat/request
238
+ # files would be unreadable -> supervisor judged unavailable).
239
+ function Write-Utf8NoBom {
240
+ param([string] $Path, [string] $Content)
241
+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
242
+ [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
243
+ }
244
+
120
245
  function Write-SupervisorHeartbeat {
246
+ param([bool] $OwnershipReady = $false)
121
247
  $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
122
- $record = @{ schema_version = 1; pid = $PID; last_seen = $now; protocol_version = 1 } | ConvertTo-Json -Compress
248
+ $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
123
249
  try {
124
250
  if (-not (Test-Path -LiteralPath $crewSupervisorRoot -PathType Container)) { New-Item -ItemType Directory -Path $crewSupervisorRoot -Force | Out-Null }
125
251
  $temp = Join-Path $crewSupervisorRoot ("heartbeat.{0}.tmp" -f $PID)
126
- Set-Content -LiteralPath $temp -Value $record -Encoding UTF8 -NoNewline
252
+ Write-Utf8NoBom -Path $temp -Content $record
127
253
  Move-Item -LiteralPath $temp -Destination $crewHeartbeatFile -Force
128
254
  } catch { /* heartbeat is best-effort */ }
129
255
  }
130
256
 
257
+ function Get-SupervisorLaunchArguments {
258
+ param([string] $ScriptPath = $PSCommandPath)
259
+ if ([string]::IsNullOrWhiteSpace($ScriptPath) -or $ScriptPath.Contains('"')) {
260
+ throw 'A valid launcher script path is required.'
261
+ }
262
+ return @(
263
+ '-NoLogo',
264
+ '-NoProfile',
265
+ '-NonInteractive',
266
+ '-ExecutionPolicy', 'Bypass',
267
+ '-File', ('"{0}"' -f $ScriptPath),
268
+ '-Mode', 'watch'
269
+ )
270
+ }
271
+
272
+ function Get-SupervisorHeartbeatRecord {
273
+ param([int] $MaxAgeSeconds = 30)
274
+ try {
275
+ if (-not (Test-Path -LiteralPath $crewHeartbeatFile -PathType Leaf)) { return $null }
276
+ $record = Get-Content -LiteralPath $crewHeartbeatFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
277
+ $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
278
+ $lastSeen = [long] $record.last_seen
279
+ $age = $now - $lastSeen
280
+ if ($record.schema_version -ne 1 -or $record.protocol_version -ne 1 -or [int] $record.pid -le 0) { return $null }
281
+ if ($age -lt -5000 -or $age -gt ([long] $MaxAgeSeconds * 1000)) { return $null }
282
+ $process = Get-Process -Id ([int] $record.pid) -ErrorAction Stop
283
+ $processStartProperty = $record.PSObject.Properties['process_started_at_utc_ticks']
284
+ if ($null -ne $processStartProperty -and $processStartProperty.Value) {
285
+ $expectedTicks = [long] $processStartProperty.Value
286
+ if ($process.StartTime.ToUniversalTime().Ticks -ne $expectedTicks) { return $null }
287
+ }
288
+ $ownershipProperty = $record.PSObject.Properties['ownership_ready']
289
+ $helperHashProperty = $record.PSObject.Properties['helper_hash']
290
+ $instanceProperty = $record.PSObject.Properties['supervisor_instance_id']
291
+ $identityReady = $null -ne $processStartProperty -and $processStartProperty.Value `
292
+ -and $null -ne $helperHashProperty -and $helperHashProperty.Value -match '^[a-f0-9]{64}$' `
293
+ -and $null -ne $instanceProperty -and -not [string]::IsNullOrWhiteSpace([string] $instanceProperty.Value)
294
+ $state = if ($null -eq $ownershipProperty) { 'legacy-v1' } elseif ($ownershipProperty.Value -eq $true -and $identityReady) { 'ready' } else { 'starting' }
295
+ return [pscustomobject]@{ State = $state; Record = $record }
296
+ } catch {
297
+ return $null
298
+ }
299
+ }
300
+
301
+ function Get-FreshSupervisorHeartbeat {
302
+ param([int] $MaxAgeSeconds = 30)
303
+ $observed = Get-SupervisorHeartbeatRecord -MaxAgeSeconds $MaxAgeSeconds
304
+ if ($observed -and $observed.State -eq 'ready') { return $observed.Record }
305
+ return $null
306
+ }
307
+
308
+ function Ensure-CrewSupervisorRunning {
309
+ param([int] $TimeoutSeconds = 90)
310
+ $watcher = $null
311
+ $observed = Get-SupervisorHeartbeatRecord
312
+ if ($observed -and $observed.State -eq 'legacy-v1') {
313
+ throw 'CREW_SUPERVISOR_UPGRADE_REQUIRED: a legacy watcher is active and must be handed off before interactive launch.'
314
+ }
315
+ if (-not $observed) {
316
+ $arguments = @(Get-SupervisorLaunchArguments -ScriptPath $PSCommandPath)
317
+ $watcher = Start-Process -FilePath 'powershell.exe' -ArgumentList $arguments -WindowStyle Hidden -PassThru
318
+ Write-LaunchLog ('Started persistent Crew supervisor; PID={0}.' -f $watcher.Id)
319
+ }
320
+
321
+ $crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
322
+ if (-not $crew) { throw 'Crew-owned 3210 service definition is missing.' }
323
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
324
+ $nextProgress = (Get-Date).AddSeconds(5)
325
+ $lastHealthError = 'not checked'
326
+ do {
327
+ $heartbeat = Get-FreshSupervisorHeartbeat
328
+ $health = Get-HealthState $crew
329
+ $lastHealthError = $health.Error
330
+ if ($heartbeat -and $health.Ready) {
331
+ Write-LaunchLog ('Persistent supervisor ready; PID={0}; 3210 runtime={1}.' -f $heartbeat.pid, $health.Version)
332
+ return
333
+ }
334
+ if ((Get-Date) -ge $nextProgress) {
335
+ Write-LaunchLog ('Waiting for Crew on 3210; {0:n0}s remaining. Health: {1}' -f [Math]::Max(0, ($deadline - (Get-Date)).TotalSeconds), $lastHealthError)
336
+ $nextProgress = (Get-Date).AddSeconds(5)
337
+ }
338
+ Start-Sleep -Milliseconds 500
339
+ } while ((Get-Date) -lt $deadline)
340
+
341
+ throw ('Persistent supervisor did not make 3210 ready within {0}s. Last health error: {1}' -f $TimeoutSeconds, $lastHealthError)
342
+ }
343
+
131
344
  function Read-RestartRequests {
132
345
  if (-not (Test-Path -LiteralPath $crewRequestsDir -PathType Container)) { return @() }
133
346
  $requests = @()
@@ -157,7 +370,7 @@ function Write-RestartResult {
157
370
  } | ConvertTo-Json -Depth 5
158
371
  $file = Join-Path $crewResultsDir ("{0}.json" -f $Request.request_id)
159
372
  $temp = Join-Path $crewResultsDir ("{0}.{1}.tmp" -f $Request.request_id, $PID)
160
- Set-Content -LiteralPath $temp -Value $result -Encoding UTF8 -NoNewline
373
+ Write-Utf8NoBom -Path $temp -Content $result
161
374
  Move-Item -LiteralPath $temp -Destination $file -Force
162
375
  } catch { /* best effort */ }
163
376
  Remove-Item -LiteralPath (Join-Path $crewRequestsDir ("{0}.json" -f $Request.request_id)) -Force -ErrorAction SilentlyContinue
@@ -253,7 +466,7 @@ function Set-MaintenanceSession {
253
466
  request_id = $Request.request_id
254
467
  } | ConvertTo-Json -Compress
255
468
  $temp = Join-Path $crewSupervisorRoot ("maintenance-session.{0}.tmp" -f $PID)
256
- Set-Content -LiteralPath $temp -Value $session -Encoding UTF8 -NoNewline
469
+ Write-Utf8NoBom -Path $temp -Content $session
257
470
  Move-Item -LiteralPath $temp -Destination $crewMaintenanceSessionFile -Force
258
471
  $back = Read-MaintenanceSession
259
472
  if ($back -is [string]) { return $false }
@@ -304,7 +517,7 @@ function Write-MaintenanceResult {
304
517
  } | ConvertTo-Json -Depth 5
305
518
  $file = Join-Path $crewMaintenanceResultsDir ("{0}.json" -f $Request.request_id)
306
519
  $temp = Join-Path $crewMaintenanceResultsDir ("{0}.{1}.tmp" -f $Request.request_id, $PID)
307
- Set-Content -LiteralPath $temp -Value $result -Encoding UTF8 -NoNewline
520
+ Write-Utf8NoBom -Path $temp -Content $result
308
521
  Move-Item -LiteralPath $temp -Destination $file -Force
309
522
  } catch { /* best effort */ }
310
523
  Remove-Item -LiteralPath (Join-Path $crewMaintenanceRequestsDir ("{0}.json" -f $Request.request_id)) -Force -ErrorAction SilentlyContinue
@@ -342,7 +555,14 @@ function Invoke-CrewMaintenanceRequests {
342
555
  $liveRuntimeId = $null
343
556
  try {
344
557
  $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
345
- if ($resp.ok -eq $true) { $liveRuntimeId = $resp.extension.runtime.runtime_id }
558
+ $runtime = $resp.extension.runtime
559
+ $canonical = $resp.ok -eq $true `
560
+ -and $runtime.service -eq 'dsh-crew-hub' `
561
+ -and $runtime.execution_plane -eq 'hub-3210' `
562
+ -and $runtime.profile -eq 'dsh-crew' `
563
+ -and ($runtime.listen_port -as [int]) -eq 3210 `
564
+ -and ($runtime.protocol_version -as [int]) -eq 1
565
+ if ($canonical) { $liveRuntimeId = $runtime.runtime_id }
346
566
  } catch { $liveRuntimeId = $null }
347
567
  if (-not $liveRuntimeId -or $liveRuntimeId -ne $request.runtime_id) {
348
568
  Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
@@ -408,7 +628,14 @@ function Invoke-CrewMaintenanceRequests {
408
628
  $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 5
409
629
  if ($resp.ok -eq $true) { $newRuntime = $resp.extension.runtime }
410
630
  } catch { $newRuntime = $null }
411
- $ok = $newRuntime -and $newRuntime.runtime_id -and ($expectedDsh -eq $null -or $newRuntime.dsh_version -eq $expectedDsh)
631
+ $ok = $newRuntime -and $newRuntime.runtime_id `
632
+ -and $newRuntime.service -eq 'dsh-crew-hub' `
633
+ -and $newRuntime.execution_plane -eq 'hub-3210' `
634
+ -and $newRuntime.profile -eq 'dsh-crew' `
635
+ -and ($newRuntime.listen_port -as [int]) -eq 3210 `
636
+ -and ($newRuntime.protocol_version -as [int]) -eq 1 `
637
+ -and ($expectedCrew -eq $null -or $newRuntime.runtime_version -eq $expectedCrew) `
638
+ -and ($expectedDsh -eq $null -or $newRuntime.dsh_version -eq $expectedDsh)
412
639
  if ($ok) {
413
640
  # Verifiable one-shot consumption: only a provably-cleared session
414
641
  # completes the transaction; otherwise the lease stays live and any
@@ -509,6 +736,27 @@ function Set-TrackedListenerIdentity {
509
736
  }
510
737
  }
511
738
 
739
+ function Test-CrewServiceOwnership {
740
+ param(
741
+ [pscustomObject] $Service,
742
+ [pscustomObject] $PortState = $null,
743
+ [object[]] $ProcessTable = $null
744
+ )
745
+ if (-not $Service.ListenerPid -or -not $Service.ListenerStartedAtUtcTicks) { return $false }
746
+ $port = if ($null -ne $PortState) { $PortState } else { Get-PortState $Service.Port }
747
+ if ($port.State -ne 'occupied' -or -not $port.Pid -or [int] $port.Pid -ne [int] $Service.ListenerPid) { return $false }
748
+ try {
749
+ $tree = if ($null -ne $ProcessTable) {
750
+ @(Get-TrackedProcessTree -Service $Service -ProcessTable $ProcessTable)
751
+ } else {
752
+ @(Get-TrackedProcessTree -Service $Service)
753
+ }
754
+ return ([int] $port.Pid -in $tree)
755
+ } catch {
756
+ return $false
757
+ }
758
+ }
759
+
512
760
  function Stop-OwnedListener {
513
761
  param([pscustomobject] $Service, [int] $ListenerPid)
514
762
  if ($Mode -ne 'watch' -or -not $Service.ListenerPid -or $ListenerPid -ne $Service.ListenerPid) {
@@ -533,8 +781,19 @@ function Stop-OwnedListener {
533
781
  return $false
534
782
  }
535
783
 
784
+ function Assert-HistoryStartAllowed {
785
+ param([string] $StatePath = (Join-Path $crewHome '..\history\active.json'))
786
+ if (-not (Test-Path -LiteralPath $StatePath)) { return }
787
+ try {
788
+ $state = Get-Content -LiteralPath $StatePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
789
+ if ($state.schemaVersion -eq 1 -and $state.phase -in @('STARTING', 'VERIFYING', 'DONE', 'FAILED', 'ROLLED_BACK')) { return }
790
+ } catch { }
791
+ throw 'HISTORY_RECOVERY_REQUIRED: Crew history maintenance is unfinished. Run dsh-crew history recover. Official 3080 is unchanged.'
792
+ }
793
+
536
794
  function Start-CrewService {
537
795
  param([pscustomobject] $Service)
796
+ Assert-HistoryStartAllowed
538
797
  $serviceRunStamp = (Get-Date).ToString('yyyyMMdd-HHmmssfff')
539
798
  $stdout = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.out.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
540
799
  $stderr = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.err.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
@@ -564,9 +823,14 @@ function Wait-CrewServices {
564
823
  $health = Get-HealthState $service
565
824
  $service.LastError = $health.Error
566
825
  if ($health.Ready) {
826
+ if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
827
+ throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
828
+ }
829
+ if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
830
+ throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
831
+ }
567
832
  $service.State = 'ready'
568
833
  $service.ConsecutiveFailures = 0
569
- if ($service.CrewOwned) { [void] (Set-TrackedListenerIdentity -Service $service) }
570
834
  Write-LaunchLog ('{0} ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
571
835
  } elseif ($service.Process -and $service.Process.HasExited) {
572
836
  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)
@@ -584,9 +848,7 @@ function Wait-CrewServices {
584
848
  }
585
849
  }
586
850
 
587
- # Non-critical diagnostic, called only after Ensure-CrewServices returns:
588
- # logs legacy bridge presence for operator awareness. Never gates, delays,
589
- # or claims 3210; never runs on the boot path.
851
+ # Explicit diagnostic helper; not called by normal startup or supervision.
590
852
  function Write-LegacyBridgeDiagnostic {
591
853
  if (Test-LegacyBridgeAvailable) {
592
854
  Write-LaunchLog 'Diagnostic: legacy 3080 bridge still answers ping; Crew launcher owns 3210 directly, bridge ignored.'
@@ -600,8 +862,7 @@ function Ensure-CrewServices {
600
862
  # Maintenance fence: while a STOPPED maintenance session is active for
601
863
  # the Crew backend, ordinary supervision must NOT auto-start 3210 —
602
864
  # the stopped window belongs to the npx lifecycle's tree swap. Only a
603
- # matching maintenance-start owns the launch right. 3080 supervision
604
- # continues normally during the window.
865
+ # matching maintenance-start owns the launch right.
605
866
  if ($service.CrewOwned -and (Test-MaintenanceSessionActive)) {
606
867
  $service.State = 'maintenance'
607
868
  $service.LastError = $null
@@ -610,10 +871,15 @@ function Ensure-CrewServices {
610
871
  $health = Get-HealthState $service
611
872
  if ($health.Ready) {
612
873
  $wasReady = $service.State -eq 'ready'
874
+ if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
875
+ throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
876
+ }
877
+ if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
878
+ throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
879
+ }
613
880
  $service.State = 'ready'
614
881
  $service.ConsecutiveFailures = 0
615
882
  $service.LastError = $null
616
- if ($service.CrewOwned -and -not $service.ListenerPid) { [void] (Set-TrackedListenerIdentity -Service $service) }
617
883
  if (-not $QuietHealthy -or -not $wasReady) {
618
884
  Write-LaunchLog ('{0} already ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
619
885
  }
@@ -677,7 +943,11 @@ function Start-ServiceSupervisor {
677
943
  return
678
944
  }
679
945
 
680
- Write-LaunchLog 'Supervisor active; monitoring 3080 and 3210 every 10 seconds.'
946
+ # Publish process identity before any 3210 startup/health wait. Handoff
947
+ # callers can now distinguish a legitimate cold-starting target watcher
948
+ # from the stale heartbeat left by the watcher it replaced.
949
+ Write-SupervisorHeartbeat -OwnershipReady $false
950
+ Write-LaunchLog 'Supervisor active; monitoring Crew-owned 3210 every 10 seconds.'
681
951
  $lastRecoveryError = $null
682
952
  $updateLockFile = Join-Path $crewHome '..\app\update-in-progress.lock'
683
953
  $staleLockNotified = $false
@@ -709,25 +979,30 @@ function Start-ServiceSupervisor {
709
979
  } else {
710
980
  $staleLockNotified = $false
711
981
  }
982
+ # Maintenance stop/start is the updater's owned process handoff and
983
+ # must remain live while the update lock fences ordinary recovery.
984
+ Invoke-CrewMaintenanceRequests
712
985
  if ($updateHeld) {
713
- Write-LaunchLog 'Update in progress; supervisor observing only, no restarts.'
986
+ Write-LaunchLog 'Update in progress; maintenance only, ordinary recovery suspended.'
714
987
  $lastRecoveryError = $null
715
988
  } else {
716
989
  # Control protocol: publish this watcher's heartbeat and consume
717
990
  # any durable restart requests the hub wrote (3210 never spawns
718
- # itself). Then consume maintenance transactions (npx cohort
719
- # migration stop/start phases). Then run the ordinary health pass.
720
- Write-SupervisorHeartbeat
991
+ # itself). Maintenance was consumed above; now run the ordinary
992
+ # health pass.
721
993
  Invoke-CrewRestartRequests
722
- Invoke-CrewMaintenanceRequests
723
994
  Ensure-CrewServices -QuietHealthy
724
995
  if ($lastRecoveryError) {
725
- Write-LaunchLog 'Supervisor recovery succeeded; both services are healthy.'
996
+ Write-LaunchLog 'Supervisor recovery succeeded; Crew-owned 3210 is healthy.'
726
997
  $lastRecoveryError = $null
727
998
  }
728
999
  }
1000
+ $ownedServices = @($services | Where-Object { $_.CrewOwned -and (Test-CrewServiceOwnership -Service $_) })
1001
+ $expectedServices = @($services | Where-Object { $_.CrewOwned })
1002
+ Write-SupervisorHeartbeat -OwnershipReady ($expectedServices.Count -gt 0 -and $ownedServices.Count -eq $expectedServices.Count)
729
1003
  } catch {
730
1004
  $recoveryError = $_.Exception.Message
1005
+ Write-SupervisorHeartbeat -OwnershipReady $false
731
1006
  if ($recoveryError -ne $lastRecoveryError) {
732
1007
  Write-LaunchLog ('Supervisor recovery failed; will retry: {0}' -f $recoveryError) 'WARN'
733
1008
  $lastRecoveryError = $recoveryError
@@ -746,6 +1021,7 @@ if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { return }
746
1021
  try {
747
1022
  New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
748
1023
  Write-LaunchLog ('Launcher started; mode={0}; user={1}' -f $Mode, $env:USERNAME)
1024
+ if ($Mode -eq 'open') { Open-OfficialFrontend }
749
1025
 
750
1026
  if (-not (Test-Path -LiteralPath $dshCli -PathType Leaf)) {
751
1027
  throw "DSH CLI was not found at $dshCli. Run: npm install -g @ran-sh/dsh-crew@latest; dsh-crew update"
@@ -753,9 +1029,7 @@ try {
753
1029
  if (-not (Test-Path -LiteralPath (Join-Path $crewHome 'profiles\dsh-crew\package.json') -PathType Leaf)) {
754
1030
  throw "The isolated dsh-crew profile is missing under $crewHome. Run: dsh-crew update"
755
1031
  }
756
- if (-not (Test-Path -LiteralPath (Join-Path $officialHome 'profiles\web\package.json') -PathType Leaf)) {
757
- throw "The official web profile is missing under $officialHome. Repair or install the official DeepSeek Harness web profile outside dsh-crew; dsh-crew never mutates the official profile."
758
- }
1032
+ # Background/watch modes remain independent of the official frontend.
759
1033
 
760
1034
  if ($Mode -eq 'watch') {
761
1035
  Start-ServiceSupervisor
@@ -763,12 +1037,10 @@ try {
763
1037
  exit 0
764
1038
  }
765
1039
 
766
- Ensure-CrewServices
767
- Write-LegacyBridgeDiagnostic
1040
+ Ensure-CrewSupervisorRunning
768
1041
 
769
1042
  if ($Mode -eq 'open') {
770
- Start-Process 'http://127.0.0.1:3080/' | Out-Null
771
- Write-LaunchLog 'Opened daily console at http://127.0.0.1:3080/'
1043
+ Write-LaunchLog 'Official frontend is on 3080; Crew is running silently on 3210.'
772
1044
  }
773
1045
  Write-LaunchLog ('Launcher completed successfully in {0:n1}s.' -f ((Get-Date) - $startedAt).TotalSeconds)
774
1046
  exit 0