@ran-sh/dsh-crew 1.1.1 → 1.2.0-rc.5

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 (84) 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 +769 -177
  23. package/official-web-bridge/lib/client.js +297 -209
  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 +91 -69
  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/client/task-telemetry.mjs +11 -4
  39. package/src/config-readiness.mjs +181 -115
  40. package/src/extension-contract.mjs +44 -26
  41. package/src/history/admission-gate.mjs +51 -0
  42. package/src/history/archive-store.mjs +261 -0
  43. package/src/history/cleanup-plan.mjs +64 -0
  44. package/src/history/http.mjs +32 -0
  45. package/src/history/operation.mjs +86 -0
  46. package/src/history/runner.mjs +51 -0
  47. package/src/history/runtime.mjs +28 -0
  48. package/src/history/service.mjs +137 -0
  49. package/src/history/state.mjs +27 -0
  50. package/src/hub/index.mjs +141 -24
  51. package/src/hub-client.mjs +17 -15
  52. package/src/image-output.mjs +71 -0
  53. package/src/install/install-legacy.mjs +126 -23
  54. package/src/install/npx-lifecycle.mjs +373 -64
  55. package/src/install/official-frontend-assets.mjs +78 -0
  56. package/src/install/payload-content.mjs +88 -0
  57. package/src/install/windows-startup.mjs +107 -19
  58. package/src/install/windows-supervisor-adapter.mjs +443 -0
  59. package/src/install/windows-supervisor-lifecycle.mjs +782 -0
  60. package/src/install/zcode.mjs +53 -14
  61. package/src/job-contracts.mjs +37 -31
  62. package/src/jobs.mjs +2 -2
  63. package/src/mcp-runtime.mjs +74 -9
  64. package/src/model-callability-contract.mjs +79 -0
  65. package/src/multimodal.mjs +0 -0
  66. package/src/official-web-bridge.mjs +0 -2
  67. package/src/provider-health.mjs +130 -124
  68. package/src/readiness-matrix.mjs +20 -15
  69. package/src/runtime-identity-contract.mjs +34 -0
  70. package/src/runtime-identity.mjs +14 -4
  71. package/src/runtime-readiness-snapshot.mjs +285 -87
  72. package/src/server.mjs +86 -76
  73. package/src/standalone-sdk.mjs +23 -0
  74. package/src/supervisor/restart-request.mjs +64 -10
  75. package/src/workflow-runtime.mjs +28 -13
  76. package/windows/start-dsh-crew.cmd +57 -57
  77. package/windows/start-dsh-crew.ps1 +327 -51
  78. package/windows/supervisor-control.ps1 +467 -0
  79. package/zcode/AGENTS.md +4 -1
  80. package/zcode/agents/ds-reviewer.md +31 -36
  81. package/zcode/agents/ds-worker.md +31 -39
  82. package/zcode/commands/dsh-config.md +17 -6
  83. package/zcode/commands/dsh-status.md +5 -4
  84. 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,149 @@ 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
+ # Windows PowerShell 5.1 keeps a JSON root array as one pipeline object;
90
+ # direct `$patch[0].insert[0]` therefore resolves `insert` as IList.Insert
91
+ # instead of indexing the JSON property. Flatten both PowerShell generations
92
+ # explicitly and read properties through PSObject so StrictMode reports a
93
+ # useful validation error instead of "property id cannot be found".
94
+ $parsed = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
95
+ $patch = @($parsed | ForEach-Object { $_ })
96
+ $root = if ($patch.Count -eq 1) { $patch[0] } else { $null }
97
+ $insertProperty = if ($null -ne $root) { $root.PSObject.Properties['insert'] } else { $null }
98
+ $insertValue = if ($null -ne $insertProperty) { $insertProperty.Value } else { $null }
99
+ $inserts = @($insertValue | ForEach-Object { $_ })
100
+ $bridge = if ($inserts.Count -eq 1) { $inserts[0] } else { $null }
101
+ $idProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['id'] } else { $null }
102
+ $nameProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['name'] } else { $null }
103
+ 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) {
104
+ throw 'Crew frontend overlay has an unexpected structure.'
105
+ }
106
+ $uri = [Uri] ([string] $nameProperty.Value)
107
+ if (-not $uri.IsFile) { throw 'Crew frontend must load from its local snapshot.' }
108
+ $entry = [IO.Path]::GetFullPath($uri.LocalPath)
109
+ $prefix = [IO.Path]::GetFullPath((Join-Path $frontendRoot 'revisions')) + [IO.Path]::DirectorySeparatorChar
110
+ if (-not $entry.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Crew frontend snapshot is outside its owned directory.' }
111
+ $metadata = Get-Content -LiteralPath (Join-Path (Split-Path -Parent $entry) 'package.json') -Raw | ConvertFrom-Json
112
+ $revision = [string] $metadata.dshCrewFrontendRevision
113
+ if ($metadata.dshCrewManagedFrontend -ne $true -or $revision -notmatch '^[a-f0-9]{64}$' -or -not (Test-Path -LiteralPath $entry -PathType Leaf)) {
114
+ throw 'Crew frontend snapshot is incomplete.'
115
+ }
116
+ return [pscustomobject]@{ Path = $path; Revision = $revision }
117
+ }
118
+
119
+ function Test-OfficialFrontendAttached {
120
+ param([string] $Revision)
121
+ try {
122
+ $response = Invoke-RestMethod -Uri 'http://127.0.0.1:3080/_dsh/dsh-crew/bridge-status' -TimeoutSec 2
123
+ return $response.ok -eq $true -and $response.surface -eq 'official-bridge' -and $response.frontend_revision -eq $Revision
124
+ } catch { return $false }
125
+ }
126
+
127
+ function Open-OfficialFrontend {
128
+ param([int] $TimeoutSeconds = 90)
129
+ $official = Resolve-OfficialHarnessCommand
130
+ $frontend = Get-OfficialFrontendOverlay
131
+ $mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
132
+ $locked = $false
133
+ try {
134
+ try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
135
+ if (-not $locked) { throw 'Another desktop launch is still starting the official frontend.' }
136
+ $port = Get-PortState -Port 3080
137
+ if ($port.State -eq 'unknown') { throw $port.Error }
138
+ if ($port.State -eq 'occupied') {
139
+ if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 is occupied by an unverified process; it was left untouched.' }
140
+ if (-not (Test-OfficialWebReady)) { throw 'The official 3080 frontend is not ready; its process was left running.' }
141
+ 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.' }
142
+ Start-Process 'http://127.0.0.1:3080/' | Out-Null
143
+ Write-LaunchLog 'Opened the existing official Harness frontend on 3080.'
144
+ return
145
+ }
146
+
147
+ $stamp = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmssfff'), $PID
148
+ $stdout = Join-Path $logRoot ('dsh-official-web-{0}.out.log' -f $stamp)
149
+ $stderr = Join-Path $logRoot ('dsh-official-web-{0}.err.log' -f $stamp)
150
+ $previousHome = $env:DSH_HOME
151
+ try {
152
+ # The official application owns its normal state; no Crew plugin/profile
153
+ # registration or repair is performed in this home by the launcher.
154
+ $env:DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
155
+ $arguments = @(('"{0}"' -f $official.Entry), 'web', '--patch', ('"{0}"' -f $frontend.Path), '--host', '127.0.0.1', '--port', '3080')
156
+ $process = Start-Process -FilePath $official.NodePath -ArgumentList $arguments -WindowStyle Hidden -PassThru `
157
+ -RedirectStandardOutput $stdout -RedirectStandardError $stderr
158
+ } finally { $env:DSH_HOME = $previousHome }
159
+ Write-LaunchLog ('Started official Harness on 3080; PID={0}. The official CLI opens the browser. Logs: {1}, {2}' -f $process.Id, $stdout, $stderr)
160
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
161
+ $nextProgress = (Get-Date).AddSeconds(5)
162
+ do {
163
+ $port = Get-PortState -Port 3080
164
+ if ($port.State -eq 'occupied') {
165
+ if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 became occupied by an unverified process; it was left untouched.' }
166
+ if ((Test-OfficialWebReady) -and (Test-OfficialFrontendAttached -Revision $frontend.Revision)) { return }
167
+ }
168
+ if ($process.HasExited) { throw ('Official Harness exited before readiness. Diagnostic log: {0}' -f $stderr) }
169
+ if ((Get-Date) -ge $nextProgress) {
170
+ Write-LaunchLog 'Waiting for the official Harness frontend on 3080...'
171
+ $nextProgress = (Get-Date).AddSeconds(5)
172
+ }
173
+ Start-Sleep -Milliseconds 500
174
+ } while ((Get-Date) -lt $deadline)
175
+ throw ('Official Harness did not become ready within {0}s; inspect {1}. No official process was stopped.' -f $TimeoutSeconds, $stderr)
176
+ } finally {
177
+ if ($locked) { $mutex.ReleaseMutex() }
178
+ $mutex.Dispose()
179
+ }
180
+ }
181
+
33
182
  function Get-HealthState {
34
183
  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.
184
+ # Preserve the installed extension health contract used by maintenance.
38
185
  if ($Service.CrewOwned) {
39
186
  try {
40
187
  $response = Invoke-RestMethod -Uri ($Service.Url + '/_dsh/dsh-crew/extension') -TimeoutSec 2
@@ -71,28 +218,10 @@ function Get-HealthState {
71
218
  return [pscustomobject]@{ Ready = $false; Version = $null; Error = $_.Exception.Message }
72
219
  }
73
220
  }
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
- }
221
+ # The launcher supervises only the Crew-owned 3210 service. Any non-Crew
222
+ # service is never started or health-gated here; the legacy 3080 bridge
223
+ # is probed only diagnostically after 3210 readiness (see Write-LegacyBridgeDiagnostic).
224
+ return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'Unknown service: only the Crew-owned 3210 service is supervised.' }
96
225
  }
97
226
 
98
227
  # Optional legacy compatibility probe: returns true only when a 3080 that
@@ -127,8 +256,9 @@ function Write-Utf8NoBom {
127
256
  }
128
257
 
129
258
  function Write-SupervisorHeartbeat {
259
+ param([bool] $OwnershipReady = $false)
130
260
  $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
131
- $record = @{ schema_version = 1; pid = $PID; last_seen = $now; protocol_version = 1 } | ConvertTo-Json -Compress
261
+ $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
132
262
  try {
133
263
  if (-not (Test-Path -LiteralPath $crewSupervisorRoot -PathType Container)) { New-Item -ItemType Directory -Path $crewSupervisorRoot -Force | Out-Null }
134
264
  $temp = Join-Path $crewSupervisorRoot ("heartbeat.{0}.tmp" -f $PID)
@@ -137,6 +267,93 @@ function Write-SupervisorHeartbeat {
137
267
  } catch { /* heartbeat is best-effort */ }
138
268
  }
139
269
 
270
+ function Get-SupervisorLaunchArguments {
271
+ param([string] $ScriptPath = $PSCommandPath)
272
+ if ([string]::IsNullOrWhiteSpace($ScriptPath) -or $ScriptPath.Contains('"')) {
273
+ throw 'A valid launcher script path is required.'
274
+ }
275
+ return @(
276
+ '-NoLogo',
277
+ '-NoProfile',
278
+ '-NonInteractive',
279
+ '-ExecutionPolicy', 'Bypass',
280
+ '-File', ('"{0}"' -f $ScriptPath),
281
+ '-Mode', 'watch'
282
+ )
283
+ }
284
+
285
+ function Get-SupervisorHeartbeatRecord {
286
+ param([int] $MaxAgeSeconds = 30)
287
+ try {
288
+ if (-not (Test-Path -LiteralPath $crewHeartbeatFile -PathType Leaf)) { return $null }
289
+ $record = Get-Content -LiteralPath $crewHeartbeatFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
290
+ $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
291
+ $lastSeen = [long] $record.last_seen
292
+ $age = $now - $lastSeen
293
+ if ($record.schema_version -ne 1 -or $record.protocol_version -ne 1 -or [int] $record.pid -le 0) { return $null }
294
+ if ($age -lt -5000 -or $age -gt ([long] $MaxAgeSeconds * 1000)) { return $null }
295
+ $process = Get-Process -Id ([int] $record.pid) -ErrorAction Stop
296
+ $processStartProperty = $record.PSObject.Properties['process_started_at_utc_ticks']
297
+ if ($null -ne $processStartProperty -and $processStartProperty.Value) {
298
+ $expectedTicks = [long] $processStartProperty.Value
299
+ if ($process.StartTime.ToUniversalTime().Ticks -ne $expectedTicks) { return $null }
300
+ }
301
+ $ownershipProperty = $record.PSObject.Properties['ownership_ready']
302
+ $helperHashProperty = $record.PSObject.Properties['helper_hash']
303
+ $instanceProperty = $record.PSObject.Properties['supervisor_instance_id']
304
+ $identityReady = $null -ne $processStartProperty -and $processStartProperty.Value `
305
+ -and $null -ne $helperHashProperty -and $helperHashProperty.Value -match '^[a-f0-9]{64}$' `
306
+ -and $null -ne $instanceProperty -and -not [string]::IsNullOrWhiteSpace([string] $instanceProperty.Value)
307
+ $state = if ($null -eq $ownershipProperty) { 'legacy-v1' } elseif ($ownershipProperty.Value -eq $true -and $identityReady) { 'ready' } else { 'starting' }
308
+ return [pscustomobject]@{ State = $state; Record = $record }
309
+ } catch {
310
+ return $null
311
+ }
312
+ }
313
+
314
+ function Get-FreshSupervisorHeartbeat {
315
+ param([int] $MaxAgeSeconds = 30)
316
+ $observed = Get-SupervisorHeartbeatRecord -MaxAgeSeconds $MaxAgeSeconds
317
+ if ($observed -and $observed.State -eq 'ready') { return $observed.Record }
318
+ return $null
319
+ }
320
+
321
+ function Ensure-CrewSupervisorRunning {
322
+ param([int] $TimeoutSeconds = 90)
323
+ $watcher = $null
324
+ $observed = Get-SupervisorHeartbeatRecord
325
+ if ($observed -and $observed.State -eq 'legacy-v1') {
326
+ throw 'CREW_SUPERVISOR_UPGRADE_REQUIRED: a legacy watcher is active and must be handed off before interactive launch.'
327
+ }
328
+ if (-not $observed) {
329
+ $arguments = @(Get-SupervisorLaunchArguments -ScriptPath $PSCommandPath)
330
+ $watcher = Start-Process -FilePath 'powershell.exe' -ArgumentList $arguments -WindowStyle Hidden -PassThru
331
+ Write-LaunchLog ('Started persistent Crew supervisor; PID={0}.' -f $watcher.Id)
332
+ }
333
+
334
+ $crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
335
+ if (-not $crew) { throw 'Crew-owned 3210 service definition is missing.' }
336
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
337
+ $nextProgress = (Get-Date).AddSeconds(5)
338
+ $lastHealthError = 'not checked'
339
+ do {
340
+ $heartbeat = Get-FreshSupervisorHeartbeat
341
+ $health = Get-HealthState $crew
342
+ $lastHealthError = $health.Error
343
+ if ($heartbeat -and $health.Ready) {
344
+ Write-LaunchLog ('Persistent supervisor ready; PID={0}; 3210 runtime={1}.' -f $heartbeat.pid, $health.Version)
345
+ return
346
+ }
347
+ if ((Get-Date) -ge $nextProgress) {
348
+ Write-LaunchLog ('Waiting for Crew on 3210; {0:n0}s remaining. Health: {1}' -f [Math]::Max(0, ($deadline - (Get-Date)).TotalSeconds), $lastHealthError)
349
+ $nextProgress = (Get-Date).AddSeconds(5)
350
+ }
351
+ Start-Sleep -Milliseconds 500
352
+ } while ((Get-Date) -lt $deadline)
353
+
354
+ throw ('Persistent supervisor did not make 3210 ready within {0}s. Last health error: {1}' -f $TimeoutSeconds, $lastHealthError)
355
+ }
356
+
140
357
  function Read-RestartRequests {
141
358
  if (-not (Test-Path -LiteralPath $crewRequestsDir -PathType Container)) { return @() }
142
359
  $requests = @()
@@ -351,7 +568,14 @@ function Invoke-CrewMaintenanceRequests {
351
568
  $liveRuntimeId = $null
352
569
  try {
353
570
  $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
354
- if ($resp.ok -eq $true) { $liveRuntimeId = $resp.extension.runtime.runtime_id }
571
+ $runtime = $resp.extension.runtime
572
+ $canonical = $resp.ok -eq $true `
573
+ -and $runtime.service -eq 'dsh-crew-hub' `
574
+ -and $runtime.execution_plane -eq 'hub-3210' `
575
+ -and $runtime.profile -eq 'dsh-crew' `
576
+ -and ($runtime.listen_port -as [int]) -eq 3210 `
577
+ -and ($runtime.protocol_version -as [int]) -eq 1
578
+ if ($canonical) { $liveRuntimeId = $runtime.runtime_id }
355
579
  } catch { $liveRuntimeId = $null }
356
580
  if (-not $liveRuntimeId -or $liveRuntimeId -ne $request.runtime_id) {
357
581
  Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
@@ -417,7 +641,14 @@ function Invoke-CrewMaintenanceRequests {
417
641
  $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 5
418
642
  if ($resp.ok -eq $true) { $newRuntime = $resp.extension.runtime }
419
643
  } catch { $newRuntime = $null }
420
- $ok = $newRuntime -and $newRuntime.runtime_id -and ($expectedDsh -eq $null -or $newRuntime.dsh_version -eq $expectedDsh)
644
+ $ok = $newRuntime -and $newRuntime.runtime_id `
645
+ -and $newRuntime.service -eq 'dsh-crew-hub' `
646
+ -and $newRuntime.execution_plane -eq 'hub-3210' `
647
+ -and $newRuntime.profile -eq 'dsh-crew' `
648
+ -and ($newRuntime.listen_port -as [int]) -eq 3210 `
649
+ -and ($newRuntime.protocol_version -as [int]) -eq 1 `
650
+ -and ($expectedCrew -eq $null -or $newRuntime.runtime_version -eq $expectedCrew) `
651
+ -and ($expectedDsh -eq $null -or $newRuntime.dsh_version -eq $expectedDsh)
421
652
  if ($ok) {
422
653
  # Verifiable one-shot consumption: only a provably-cleared session
423
654
  # completes the transaction; otherwise the lease stays live and any
@@ -518,6 +749,27 @@ function Set-TrackedListenerIdentity {
518
749
  }
519
750
  }
520
751
 
752
+ function Test-CrewServiceOwnership {
753
+ param(
754
+ [pscustomObject] $Service,
755
+ [pscustomObject] $PortState = $null,
756
+ [object[]] $ProcessTable = $null
757
+ )
758
+ if (-not $Service.ListenerPid -or -not $Service.ListenerStartedAtUtcTicks) { return $false }
759
+ $port = if ($null -ne $PortState) { $PortState } else { Get-PortState $Service.Port }
760
+ if ($port.State -ne 'occupied' -or -not $port.Pid -or [int] $port.Pid -ne [int] $Service.ListenerPid) { return $false }
761
+ try {
762
+ $tree = if ($null -ne $ProcessTable) {
763
+ @(Get-TrackedProcessTree -Service $Service -ProcessTable $ProcessTable)
764
+ } else {
765
+ @(Get-TrackedProcessTree -Service $Service)
766
+ }
767
+ return ([int] $port.Pid -in $tree)
768
+ } catch {
769
+ return $false
770
+ }
771
+ }
772
+
521
773
  function Stop-OwnedListener {
522
774
  param([pscustomobject] $Service, [int] $ListenerPid)
523
775
  if ($Mode -ne 'watch' -or -not $Service.ListenerPid -or $ListenerPid -ne $Service.ListenerPid) {
@@ -542,8 +794,19 @@ function Stop-OwnedListener {
542
794
  return $false
543
795
  }
544
796
 
797
+ function Assert-HistoryStartAllowed {
798
+ param([string] $StatePath = (Join-Path $crewHome '..\history\active.json'))
799
+ if (-not (Test-Path -LiteralPath $StatePath)) { return }
800
+ try {
801
+ $state = Get-Content -LiteralPath $StatePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
802
+ if ($state.schemaVersion -eq 1 -and $state.phase -in @('STARTING', 'VERIFYING', 'DONE', 'FAILED', 'ROLLED_BACK')) { return }
803
+ } catch { }
804
+ throw 'HISTORY_RECOVERY_REQUIRED: Crew history maintenance is unfinished. Run dsh-crew history recover. Official 3080 is unchanged.'
805
+ }
806
+
545
807
  function Start-CrewService {
546
808
  param([pscustomobject] $Service)
809
+ Assert-HistoryStartAllowed
547
810
  $serviceRunStamp = (Get-Date).ToString('yyyyMMdd-HHmmssfff')
548
811
  $stdout = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.out.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
549
812
  $stderr = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.err.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
@@ -573,9 +836,14 @@ function Wait-CrewServices {
573
836
  $health = Get-HealthState $service
574
837
  $service.LastError = $health.Error
575
838
  if ($health.Ready) {
839
+ if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
840
+ throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
841
+ }
842
+ if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
843
+ throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
844
+ }
576
845
  $service.State = 'ready'
577
846
  $service.ConsecutiveFailures = 0
578
- if ($service.CrewOwned) { [void] (Set-TrackedListenerIdentity -Service $service) }
579
847
  Write-LaunchLog ('{0} ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
580
848
  } elseif ($service.Process -and $service.Process.HasExited) {
581
849
  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)
@@ -593,9 +861,7 @@ function Wait-CrewServices {
593
861
  }
594
862
  }
595
863
 
596
- # Non-critical diagnostic, called only after Ensure-CrewServices returns:
597
- # logs legacy bridge presence for operator awareness. Never gates, delays,
598
- # or claims 3210; never runs on the boot path.
864
+ # Explicit diagnostic helper; not called by normal startup or supervision.
599
865
  function Write-LegacyBridgeDiagnostic {
600
866
  if (Test-LegacyBridgeAvailable) {
601
867
  Write-LaunchLog 'Diagnostic: legacy 3080 bridge still answers ping; Crew launcher owns 3210 directly, bridge ignored.'
@@ -609,8 +875,7 @@ function Ensure-CrewServices {
609
875
  # Maintenance fence: while a STOPPED maintenance session is active for
610
876
  # the Crew backend, ordinary supervision must NOT auto-start 3210 —
611
877
  # the stopped window belongs to the npx lifecycle's tree swap. Only a
612
- # matching maintenance-start owns the launch right. 3080 supervision
613
- # continues normally during the window.
878
+ # matching maintenance-start owns the launch right.
614
879
  if ($service.CrewOwned -and (Test-MaintenanceSessionActive)) {
615
880
  $service.State = 'maintenance'
616
881
  $service.LastError = $null
@@ -619,10 +884,15 @@ function Ensure-CrewServices {
619
884
  $health = Get-HealthState $service
620
885
  if ($health.Ready) {
621
886
  $wasReady = $service.State -eq 'ready'
887
+ if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
888
+ throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
889
+ }
890
+ if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
891
+ throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
892
+ }
622
893
  $service.State = 'ready'
623
894
  $service.ConsecutiveFailures = 0
624
895
  $service.LastError = $null
625
- if ($service.CrewOwned -and -not $service.ListenerPid) { [void] (Set-TrackedListenerIdentity -Service $service) }
626
896
  if (-not $QuietHealthy -or -not $wasReady) {
627
897
  Write-LaunchLog ('{0} already ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
628
898
  }
@@ -686,7 +956,11 @@ function Start-ServiceSupervisor {
686
956
  return
687
957
  }
688
958
 
689
- Write-LaunchLog 'Supervisor active; monitoring 3080 and 3210 every 10 seconds.'
959
+ # Publish process identity before any 3210 startup/health wait. Handoff
960
+ # callers can now distinguish a legitimate cold-starting target watcher
961
+ # from the stale heartbeat left by the watcher it replaced.
962
+ Write-SupervisorHeartbeat -OwnershipReady $false
963
+ Write-LaunchLog 'Supervisor active; monitoring Crew-owned 3210 every 10 seconds.'
690
964
  $lastRecoveryError = $null
691
965
  $updateLockFile = Join-Path $crewHome '..\app\update-in-progress.lock'
692
966
  $staleLockNotified = $false
@@ -718,25 +992,30 @@ function Start-ServiceSupervisor {
718
992
  } else {
719
993
  $staleLockNotified = $false
720
994
  }
995
+ # Maintenance stop/start is the updater's owned process handoff and
996
+ # must remain live while the update lock fences ordinary recovery.
997
+ Invoke-CrewMaintenanceRequests
721
998
  if ($updateHeld) {
722
- Write-LaunchLog 'Update in progress; supervisor observing only, no restarts.'
999
+ Write-LaunchLog 'Update in progress; maintenance only, ordinary recovery suspended.'
723
1000
  $lastRecoveryError = $null
724
1001
  } else {
725
1002
  # Control protocol: publish this watcher's heartbeat and consume
726
1003
  # any durable restart requests the hub wrote (3210 never spawns
727
- # itself). Then consume maintenance transactions (npx cohort
728
- # migration stop/start phases). Then run the ordinary health pass.
729
- Write-SupervisorHeartbeat
1004
+ # itself). Maintenance was consumed above; now run the ordinary
1005
+ # health pass.
730
1006
  Invoke-CrewRestartRequests
731
- Invoke-CrewMaintenanceRequests
732
1007
  Ensure-CrewServices -QuietHealthy
733
1008
  if ($lastRecoveryError) {
734
- Write-LaunchLog 'Supervisor recovery succeeded; both services are healthy.'
1009
+ Write-LaunchLog 'Supervisor recovery succeeded; Crew-owned 3210 is healthy.'
735
1010
  $lastRecoveryError = $null
736
1011
  }
737
1012
  }
1013
+ $ownedServices = @($services | Where-Object { $_.CrewOwned -and (Test-CrewServiceOwnership -Service $_) })
1014
+ $expectedServices = @($services | Where-Object { $_.CrewOwned })
1015
+ Write-SupervisorHeartbeat -OwnershipReady ($expectedServices.Count -gt 0 -and $ownedServices.Count -eq $expectedServices.Count)
738
1016
  } catch {
739
1017
  $recoveryError = $_.Exception.Message
1018
+ Write-SupervisorHeartbeat -OwnershipReady $false
740
1019
  if ($recoveryError -ne $lastRecoveryError) {
741
1020
  Write-LaunchLog ('Supervisor recovery failed; will retry: {0}' -f $recoveryError) 'WARN'
742
1021
  $lastRecoveryError = $recoveryError
@@ -755,6 +1034,7 @@ if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { return }
755
1034
  try {
756
1035
  New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
757
1036
  Write-LaunchLog ('Launcher started; mode={0}; user={1}' -f $Mode, $env:USERNAME)
1037
+ if ($Mode -eq 'open') { Open-OfficialFrontend }
758
1038
 
759
1039
  if (-not (Test-Path -LiteralPath $dshCli -PathType Leaf)) {
760
1040
  throw "DSH CLI was not found at $dshCli. Run: npm install -g @ran-sh/dsh-crew@latest; dsh-crew update"
@@ -762,9 +1042,7 @@ try {
762
1042
  if (-not (Test-Path -LiteralPath (Join-Path $crewHome 'profiles\dsh-crew\package.json') -PathType Leaf)) {
763
1043
  throw "The isolated dsh-crew profile is missing under $crewHome. Run: dsh-crew update"
764
1044
  }
765
- if (-not (Test-Path -LiteralPath (Join-Path $officialHome 'profiles\web\package.json') -PathType Leaf)) {
766
- 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."
767
- }
1045
+ # Background/watch modes remain independent of the official frontend.
768
1046
 
769
1047
  if ($Mode -eq 'watch') {
770
1048
  Start-ServiceSupervisor
@@ -772,12 +1050,10 @@ try {
772
1050
  exit 0
773
1051
  }
774
1052
 
775
- Ensure-CrewServices
776
- Write-LegacyBridgeDiagnostic
1053
+ Ensure-CrewSupervisorRunning
777
1054
 
778
1055
  if ($Mode -eq 'open') {
779
- Start-Process 'http://127.0.0.1:3080/' | Out-Null
780
- Write-LaunchLog 'Opened daily console at http://127.0.0.1:3080/'
1056
+ Write-LaunchLog 'Official frontend is on 3080; Crew is running silently on 3210.'
781
1057
  }
782
1058
  Write-LaunchLog ('Launcher completed successfully in {0:n1}s.' -f ((Get-Date) - $startedAt).TotalSeconds)
783
1059
  exit 0