@ran-sh/dsh-crew 0.5.2 → 0.5.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.
@@ -0,0 +1,326 @@
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
+ $officialHome = Join-Path $env:USERPROFILE '.dsh'
13
+ $dshCli = Join-Path $crewHome 'runtime\node_modules\.bin\dsh.cmd'
14
+ $logRoot = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
15
+ $launcherLog = Join-Path $logRoot 'dsh-crew-launcher.log'
16
+ $startedAt = Get-Date
17
+ $services = @(
18
+ [pscustomobject]@{ Name = 'Crew backend'; Profile = 'dsh-crew'; Home = $crewHome; Port = 3210; Url = 'http://127.0.0.1:3210'; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null },
19
+ [pscustomobject]@{ Name = 'Official UI'; Profile = 'web'; Home = $officialHome; Port = 3080; Url = 'http://127.0.0.1:3080'; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null }
20
+ )
21
+
22
+ function Write-LaunchLog {
23
+ param([string] $Message, [ValidateSet('INFO', 'WARN', 'ERROR')] [string] $Level = 'INFO')
24
+ $line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffK'), $Level, $Message
25
+ Add-Content -LiteralPath $launcherLog -Value $line -Encoding UTF8
26
+ if ($Mode -eq 'open') {
27
+ if ($Level -eq 'ERROR') { Write-Host $line -ForegroundColor Red }
28
+ elseif ($Level -eq 'WARN') { Write-Host $line -ForegroundColor Yellow }
29
+ else { Write-Host $line }
30
+ }
31
+ }
32
+
33
+ function Get-HealthState {
34
+ param([pscustomobject] $Service)
35
+ try {
36
+ $response = Invoke-RestMethod -Uri ($Service.Url + '/_dsh/dsh-crew/extension') -TimeoutSec 2
37
+ $version = $response.extension.runtime.runtime_version
38
+ if ($response.ok -eq $true -and $version) {
39
+ return [pscustomobject]@{ Ready = $true; Version = [string] $version; Error = $null }
40
+ }
41
+ return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'Response did not contain a ready runtime contract.' }
42
+ } catch {
43
+ return [pscustomobject]@{ Ready = $false; Version = $null; Error = $_.Exception.Message }
44
+ }
45
+ }
46
+
47
+ function Get-PortState {
48
+ param([int] $Port)
49
+ try {
50
+ $listeners = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners()
51
+ $occupied = @($listeners | Where-Object Port -eq $Port).Count -gt 0
52
+ if (-not $occupied) {
53
+ return [pscustomobject]@{ State = 'free'; Error = $null; Pid = $null }
54
+ }
55
+
56
+ $ownerPid = $null
57
+ try {
58
+ $ownerPid = (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop | Select-Object -First 1).OwningProcess
59
+ } catch { }
60
+ return [pscustomobject]@{ State = 'occupied'; Error = $null; Pid = $ownerPid }
61
+ } catch {
62
+ return [pscustomobject]@{ State = 'unknown'; Error = ('Listener enumeration failed: {0}' -f $_.Exception.Message); Pid = $null }
63
+ }
64
+ }
65
+
66
+ function Test-TrackedProcessIdentity {
67
+ param([int] $ProcessId, [long] $ExpectedStartTicks, [object[]] $ProcessTable)
68
+ if ($ProcessId -lt 1 -or $ExpectedStartTicks -lt 1) { return $false }
69
+ $record = @($ProcessTable | Where-Object { [int] $_.ProcessId -eq $ProcessId } | Select-Object -First 1)
70
+ if ($record.Count -eq 0) { return $false }
71
+ $startTicks = $null
72
+ if ($record[0].PSObject.Properties.Name -contains 'StartTicks') {
73
+ $startTicks = [long] $record[0].StartTicks
74
+ } else {
75
+ try { $startTicks = (Get-Process -Id $ProcessId -ErrorAction Stop).StartTime.ToUniversalTime().Ticks } catch { return $false }
76
+ }
77
+ return $startTicks -eq $ExpectedStartTicks
78
+ }
79
+
80
+ function Get-TrackedProcessTree {
81
+ param([pscustomobject] $Service, [object[]] $ProcessTable = $null)
82
+ $processes = if ($null -ne $ProcessTable) { @($ProcessTable) } else { @(Get-CimInstance Win32_Process -ErrorAction Stop) }
83
+ $owned = [System.Collections.Generic.HashSet[int]]::new()
84
+ $rootMatches = Test-TrackedProcessIdentity -ProcessId $Service.RootPid -ExpectedStartTicks $Service.RootStartedAtUtcTicks -ProcessTable $processes
85
+ $hasTrackedListener = $Service.ListenerPid -and $Service.ListenerStartedAtUtcTicks
86
+ if ($hasTrackedListener) {
87
+ $listenerMatches = Test-TrackedProcessIdentity -ProcessId $Service.ListenerPid -ExpectedStartTicks $Service.ListenerStartedAtUtcTicks -ProcessTable $processes
88
+ if (-not $listenerMatches) { return @() }
89
+ [void] $owned.Add([int] $Service.ListenerPid)
90
+ } elseif (-not $rootMatches) {
91
+ return @()
92
+ }
93
+ if ($rootMatches) { [void] $owned.Add([int] $Service.RootPid) }
94
+ do {
95
+ $added = $false
96
+ foreach ($candidate in $processes) {
97
+ $candidateId = [int] $candidate.ProcessId
98
+ $parentId = [int] $candidate.ParentProcessId
99
+ if ($owned.Contains($parentId) -and $owned.Add($candidateId)) { $added = $true }
100
+ }
101
+ } while ($added)
102
+ return @($owned | ForEach-Object { [int] $_ })
103
+ }
104
+
105
+ function Set-TrackedListenerIdentity {
106
+ param([pscustomobject] $Service)
107
+ $port = Get-PortState $Service.Port
108
+ if ($port.State -ne 'occupied' -or -not $port.Pid) { return $false }
109
+ $tree = @(Get-TrackedProcessTree -Service $Service)
110
+ if ($port.Pid -notin $tree) { return $false }
111
+ try {
112
+ $listener = Get-Process -Id $port.Pid -ErrorAction Stop
113
+ $Service.ListenerPid = [int] $port.Pid
114
+ $Service.ListenerStartedAtUtcTicks = $listener.StartTime.ToUniversalTime().Ticks
115
+ return $true
116
+ } catch {
117
+ return $false
118
+ }
119
+ }
120
+
121
+ function Stop-OwnedListener {
122
+ param([pscustomobject] $Service, [int] $ListenerPid)
123
+ if ($Mode -ne 'watch' -or -not $Service.ListenerPid -or $ListenerPid -ne $Service.ListenerPid) {
124
+ return $false
125
+ }
126
+ try {
127
+ $ownedProcessIds = @(Get-TrackedProcessTree -Service $Service)
128
+ if ($ListenerPid -notin $ownedProcessIds) { return $false }
129
+ 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'
130
+ foreach ($processId in $ownedProcessIds) {
131
+ Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
132
+ }
133
+ $deadline = (Get-Date).AddSeconds(5)
134
+ do {
135
+ $port = Get-PortState $Service.Port
136
+ if ($port.State -eq 'free') { return $true }
137
+ Start-Sleep -Milliseconds 250
138
+ } while ((Get-Date) -lt $deadline)
139
+ } catch {
140
+ Write-LaunchLog ('Supervisor could not stop its owned listener safely: {0}' -f $_.Exception.Message) 'WARN'
141
+ }
142
+ return $false
143
+ }
144
+
145
+ function Start-CrewService {
146
+ param([pscustomobject] $Service)
147
+ $serviceRunStamp = (Get-Date).ToString('yyyyMMdd-HHmmssfff')
148
+ $stdout = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.out.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
149
+ $stderr = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.err.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
150
+ $previousHome = $env:DSH_HOME
151
+ try {
152
+ $env:DSH_HOME = $Service.Home
153
+ $arguments = @('--profile', $Service.Profile, '--host', '127.0.0.1', '--port', [string] $Service.Port, '--no-open')
154
+ $process = Start-Process -FilePath $dshCli -ArgumentList $arguments -WindowStyle Hidden -PassThru `
155
+ -RedirectStandardOutput $stdout -RedirectStandardError $stderr
156
+ $Service.Process = $process
157
+ $Service.RootPid = $process.Id
158
+ $Service.RootStartedAtUtcTicks = $process.StartTime.ToUniversalTime().Ticks
159
+ $Service.ListenerPid = $null
160
+ $Service.ListenerStartedAtUtcTicks = $null
161
+ $Service.ConsecutiveFailures = 0
162
+ $Service.State = 'starting'
163
+ Write-LaunchLog ('Started {0} on port {1}; PID={2}; stdout={3}; stderr={4}' -f $Service.Profile, $Service.Port, $process.Id, $stdout, $stderr)
164
+ } finally {
165
+ $env:DSH_HOME = $previousHome
166
+ }
167
+ }
168
+
169
+ function Wait-CrewServices {
170
+ $deadline = (Get-Date).AddSeconds(90)
171
+ while (@($services | Where-Object State -eq 'starting').Count -gt 0 -and (Get-Date) -lt $deadline) {
172
+ foreach ($service in ($services | Where-Object State -eq 'starting')) {
173
+ $health = Get-HealthState $service
174
+ $service.LastError = $health.Error
175
+ if ($health.Ready) {
176
+ $service.State = 'ready'
177
+ $service.ConsecutiveFailures = 0
178
+ [void] (Set-TrackedListenerIdentity -Service $service)
179
+ Write-LaunchLog ('{0} ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
180
+ } elseif ($service.Process -and $service.Process.HasExited) {
181
+ 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)
182
+ }
183
+ }
184
+ if (@($services | Where-Object State -eq 'starting').Count -gt 0) { Start-Sleep -Milliseconds 500 }
185
+ }
186
+
187
+ $notReady = @($services | Where-Object State -ne 'ready')
188
+ if ($notReady.Count -gt 0) {
189
+ $details = ($notReady | ForEach-Object { '{0}:{1} ({2})' -f $_.Profile, $_.Port, $_.LastError }) -join '; '
190
+ throw "Startup health deadline exceeded: $details"
191
+ }
192
+ }
193
+
194
+ function Ensure-CrewServices {
195
+ param([switch] $QuietHealthy)
196
+
197
+ foreach ($service in $services) {
198
+ $health = Get-HealthState $service
199
+ if ($health.Ready) {
200
+ $wasReady = $service.State -eq 'ready'
201
+ $service.State = 'ready'
202
+ $service.ConsecutiveFailures = 0
203
+ $service.LastError = $null
204
+ if (-not $service.ListenerPid) { [void] (Set-TrackedListenerIdentity -Service $service) }
205
+ if (-not $QuietHealthy -or -not $wasReady) {
206
+ Write-LaunchLog ('{0} already ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
207
+ }
208
+ continue
209
+ }
210
+
211
+ $service.LastError = $health.Error
212
+ $service.ConsecutiveFailures += 1
213
+ if ($service.Process -and $service.Process.HasExited) {
214
+ Write-LaunchLog ('{0} process exited after startup; PID={1}; exit={2}' -f $service.Name, $service.Process.Id, $service.Process.ExitCode) 'WARN'
215
+ $service.Process = $null
216
+ }
217
+ $service.State = 'pending'
218
+
219
+ $port = Get-PortState $service.Port
220
+ if ($port.State -eq 'occupied') {
221
+ if ($service.ConsecutiveFailures -lt 3) {
222
+ 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)
223
+ }
224
+ if (Stop-OwnedListener -Service $service -ListenerPid $port.Pid) {
225
+ $service.Process = $null
226
+ $service.RootPid = $null
227
+ $service.RootStartedAtUtcTicks = $null
228
+ $service.ListenerPid = $null
229
+ $service.ListenerStartedAtUtcTicks = $null
230
+ $service.ConsecutiveFailures = 0
231
+ $port = Get-PortState $service.Port
232
+ }
233
+ }
234
+ if ($port.State -eq 'occupied') {
235
+ $owner = if ($port.Pid) { "; listener PID=$($port.Pid)" } else { '' }
236
+ throw ('Port {0} is occupied, but {1} failed its health contract{2}. Health error: {3}' -f $service.Port, $service.Name, $owner, $health.Error)
237
+ }
238
+ if ($port.State -ne 'free') {
239
+ throw ('Could not determine whether port {0} is available: {1}' -f $service.Port, $port.Error)
240
+ }
241
+ if ($Mode -eq 'watch') {
242
+ Write-LaunchLog ('Supervisor detected {0} unavailable on {1}; restarting it.' -f $service.Name, $service.Port) 'WARN'
243
+ }
244
+ Start-CrewService $service
245
+ }
246
+
247
+ Wait-CrewServices
248
+ }
249
+
250
+ function Start-ServiceSupervisor {
251
+ $mutex = [System.Threading.Mutex]::new($false, 'Local\DSHCrewServiceSupervisor')
252
+ $ownsMutex = $false
253
+ try {
254
+ try {
255
+ $ownsMutex = $mutex.WaitOne(0)
256
+ } catch [System.Threading.AbandonedMutexException] {
257
+ $ownsMutex = $true
258
+ }
259
+ if (-not $ownsMutex) {
260
+ Write-LaunchLog 'Supervisor already active; duplicate watcher exiting.'
261
+ return
262
+ }
263
+
264
+ Write-LaunchLog 'Supervisor active; monitoring 3080 and 3210 every 10 seconds.'
265
+ $lastRecoveryError = $null
266
+ while ($true) {
267
+ try {
268
+ Ensure-CrewServices -QuietHealthy
269
+ if ($lastRecoveryError) {
270
+ Write-LaunchLog 'Supervisor recovery succeeded; both services are healthy.'
271
+ $lastRecoveryError = $null
272
+ }
273
+ } catch {
274
+ $recoveryError = $_.Exception.Message
275
+ if ($recoveryError -ne $lastRecoveryError) {
276
+ Write-LaunchLog ('Supervisor recovery failed; will retry: {0}' -f $recoveryError) 'WARN'
277
+ $lastRecoveryError = $recoveryError
278
+ }
279
+ }
280
+ Start-Sleep -Seconds 10
281
+ }
282
+ } finally {
283
+ if ($ownsMutex) { $mutex.ReleaseMutex() }
284
+ $mutex.Dispose()
285
+ }
286
+ }
287
+
288
+ if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { return }
289
+
290
+ try {
291
+ New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
292
+ Write-LaunchLog ('Launcher started; mode={0}; user={1}' -f $Mode, $env:USERNAME)
293
+
294
+ if (-not (Test-Path -LiteralPath $dshCli -PathType Leaf)) {
295
+ throw "DSH CLI was not found at $dshCli. Run: npm install -g @ran-sh/dsh-crew@latest; dsh-crew update"
296
+ }
297
+ if (-not (Test-Path -LiteralPath (Join-Path $crewHome 'profiles\dsh-crew\package.json') -PathType Leaf)) {
298
+ throw "The isolated dsh-crew profile is missing under $crewHome. Run: dsh-crew update"
299
+ }
300
+ if (-not (Test-Path -LiteralPath (Join-Path $officialHome 'profiles\web\package.json') -PathType Leaf)) {
301
+ throw "The official web profile is missing under $officialHome. Run: dsh-crew integrate"
302
+ }
303
+
304
+ if ($Mode -eq 'watch') {
305
+ Start-ServiceSupervisor
306
+ Write-LaunchLog 'Supervisor stopped.' 'WARN'
307
+ exit 0
308
+ }
309
+
310
+ Ensure-CrewServices
311
+
312
+ if ($Mode -eq 'open') {
313
+ Start-Process 'http://127.0.0.1:3080/' | Out-Null
314
+ Write-LaunchLog 'Opened daily console at http://127.0.0.1:3080/'
315
+ }
316
+ Write-LaunchLog ('Launcher completed successfully in {0:n1}s.' -f ((Get-Date) - $startedAt).TotalSeconds)
317
+ exit 0
318
+ } catch {
319
+ Write-LaunchLog $_.Exception.Message 'ERROR'
320
+ if ($Mode -eq 'open') {
321
+ Write-Host ''
322
+ Write-Host "Diagnostic log: $launcherLog" -ForegroundColor Yellow
323
+ Write-Host 'Startup failed. Review the diagnostic log and the service stdout/stderr paths recorded above.' -ForegroundColor Yellow
324
+ }
325
+ exit 1
326
+ }
@@ -4,6 +4,6 @@ Dim shell, launcher, command
4
4
  Set shell = CreateObject("WScript.Shell")
5
5
  launcher = "__LAUNCHER__"
6
6
  command = shell.ExpandEnvironmentStrings("%COMSPEC%") & " /d /c " & _
7
- Chr(34) & Chr(34) & launcher & Chr(34) & Chr(34)
7
+ Chr(34) & Chr(34) & launcher & Chr(34) & " --watch" & Chr(34)
8
8
 
9
9
  shell.Run command, 0, False
package/zcode/AGENTS.md CHANGED
@@ -10,6 +10,11 @@ work only after its live capability and readiness surfaces have been checked.
10
10
  repository/worktree and Result Contract boundaries. Keep planning, ambiguous
11
11
  requirements, integration, external side effects and final communication in
12
12
  the host agent.
13
+ - Dispatch selected work asynchronously with `dsh_spawn_worker`, save its
14
+ workflow ID, and poll that same workflow with `dsh_worker_result` using
15
+ `wait_seconds: 10`. A bounded wait that returns a nonterminal state is not a
16
+ failure when `dsh_worker_status` confirms that workflow is still running;
17
+ continue polling it and never start a duplicate workflow.
13
18
  - If Crew is selected and any required capability is unavailable, non-callable,
14
19
  or returns an unknown/runtime/configuration/credential/routing/timeout error,
15
20
  pause. Report the evidence and wait for the operator to choose repair Crew or
@@ -5,6 +5,7 @@ mcpServers:
5
5
  - dsh-crew
6
6
  tools:
7
7
  - mcp__dsh-crew__dsh_run_worker
8
+ - mcp__dsh-crew__dsh_spawn_worker
8
9
  - mcp__dsh-crew__dsh_worker_status
9
10
  - mcp__dsh-crew__dsh_worker_result
10
11
  - mcp__dsh-crew__dsh_worker_cancel
@@ -13,7 +14,23 @@ tools:
13
14
 
14
15
  You are a thin, read-only dispatcher. Never edit files or implement fixes.
15
16
 
16
- Pass the review request verbatim to `dsh_run_worker` with role `reviewer` and
17
- the current workspace as `cwd`; omit effort unless explicitly requested. Wait
18
- for the result and return its Review Findings, Evidence, Risks and Verdict.
19
- Treat failing tests or incomplete evidence as not approved.
17
+ Check `dsh_worker_config` before dispatch. Derive one bounded, read-only review
18
+ task that contains only the code, acceptance criteria and validation evidence
19
+ the Reviewer must inspect. Keep harness-reporting requirements in this
20
+ dispatcher, then call `dsh_spawn_worker` with role `reviewer` and the current
21
+ workspace as `cwd`; omit effort unless explicitly requested. Save the returned
22
+ workflow ID.
23
+
24
+ Poll that same workflow through `dsh_worker_result` with `wait_seconds: 10`.
25
+ If a bounded result wait expires or returns a nonterminal state, check
26
+ `dsh_worker_status` once. When it confirms the workflow is still running,
27
+ continue polling the same workflow; never start a duplicate. A genuine
28
+ transport, runtime, configuration, credential or routing error is a hard stop
29
+ and must be reported to the operator.
30
+
31
+ Treat the workflow ID as host structured-result metadata; never ask the
32
+ Reviewer to discover or report its workflow ID, provider, model or other
33
+ harness metadata. Do not forward workflow ID, provider, model or status-reporting
34
+ fields as Reviewer task requirements. Combine the host-owned metadata with
35
+ Review Findings, Evidence, Risks and Verdict. Treat failing tests or incomplete
36
+ evidence as not approved.
@@ -14,7 +14,26 @@ tools:
14
14
 
15
15
  You are a thin dispatcher. You never do the task yourself.
16
16
 
17
- Pass the task verbatim to `dsh_run_worker` with role `worker` and the current
18
- workspace as `cwd`; omit effort unless the task explicitly requests it. Wait
19
- for the result. If it is `done`, return the result and its evidence footer. If
20
- it is not done, report the error and stop reason clearly and stop.
17
+ Check `dsh_worker_config` before dispatch. Derive one bounded, domain-only task
18
+ for the Worker, preserving the requested implementation and validation scope.
19
+ Keep harness-reporting requirements in this dispatcher, then call
20
+ `dsh_spawn_worker` with role `worker` and the current workspace as `cwd`; omit
21
+ effort unless the task explicitly requests it. Save the returned workflow ID.
22
+ When the operator explicitly requests read-only search or analysis that should
23
+ make zero file changes, pass `constraints: { allow_no_changes: true }`.
24
+ Otherwise omit that constraint so implementation work still fails closed when
25
+ the reported diff and isolated workspace disagree.
26
+
27
+ Poll that same workflow through `dsh_worker_result` with `wait_seconds: 10`.
28
+ If a bounded result wait expires or returns a nonterminal state, check
29
+ `dsh_worker_status` once. When it confirms the workflow is still running,
30
+ continue polling the same workflow; never start a duplicate. A genuine
31
+ transport, runtime, configuration, credential or routing error is a hard stop
32
+ and must be reported to the operator.
33
+
34
+ Treat the workflow ID as host structured-result metadata; never ask the Worker
35
+ to discover or report its workflow ID, provider, model or other harness
36
+ metadata. Do not forward workflow ID, provider, model or status-reporting fields
37
+ as Worker task requirements. If the workflow is `done`, combine the host-owned
38
+ metadata with its compact result and evidence footer. Otherwise report the
39
+ terminal error and stop reason clearly.