acstack 0.0.0-stage → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.claude-plugin/plugin.json +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +389 -2
  4. package/THIRD_PARTY_NOTICES +14 -0
  5. package/TROUBLESHOOTING.md +225 -0
  6. package/agents/codex/frontend-verifier.toml +352 -0
  7. package/agents/codex/task-checker.toml +345 -0
  8. package/agents/codex/task-executor.toml +157 -0
  9. package/agents/codex/test-writer.toml +149 -0
  10. package/agents/frontend-verifier.md +351 -0
  11. package/agents/task-checker.md +344 -0
  12. package/agents/task-executor.md +156 -0
  13. package/agents/test-writer.md +148 -0
  14. package/assets/playwriter-bridge/build-launcher.ps1 +32 -0
  15. package/assets/playwriter-bridge/doctor.ps1 +450 -0
  16. package/assets/playwriter-bridge/launcher-src.cs +131 -0
  17. package/assets/playwriter-bridge/opener.js +330 -0
  18. package/assets/playwriter-bridge/opener.ps1 +63 -0
  19. package/assets/playwriter-bridge/register-tasks.ps1 +328 -0
  20. package/assets/playwriter-bridge/relay.ps1 +50 -0
  21. package/assets/playwriter-bridge/stop.ps1 +125 -0
  22. package/assets/playwriter-bridge/sync.ps1 +271 -0
  23. package/assets/playwriter-bridge/tunnel.ps1 +325 -0
  24. package/assets/playwriter-bridge/vm-free-port.sh +166 -0
  25. package/assets/terminal-workspace/client-keybindings.md +87 -0
  26. package/assets/terminal-workspace/config.example.yaml +334 -0
  27. package/assets/terminal-workspace/setup.sh +4404 -0
  28. package/cli/README.md +47 -0
  29. package/cli/bin/acstack.js +3 -0
  30. package/cli/dist/index.js +25984 -0
  31. package/cli/dist/index.js.map +7 -0
  32. package/cli/package.json +59 -0
  33. package/docs/adr/0001-ste-writing-standard.md +16 -0
  34. package/docs/adr/0002-version-gate-not-auto-bump.md +35 -0
  35. package/docs/adr/0003-handoff-ack-command.md +19 -0
  36. package/docs/adr/0004-dev-setup-per-host.md +13 -0
  37. package/docs/adr/0005-two-orchestrators-per-host.md +25 -0
  38. package/docs/adr/0006-document-opener-service.md +89 -0
  39. package/docs/adr/0007-lead-check-reads-the-transcript.md +87 -0
  40. package/docs/adr/0008-cli-audits-skill-interviews.md +37 -0
  41. package/docs/adr/0009-verify-the-write-not-the-writer.md +51 -0
  42. package/docs/adr/0010-promote-dev-to-main-by-fast-forward.md +115 -0
  43. package/docs/adr/0011-node-header-is-the-edge-encoding.md +20 -0
  44. package/docs/adr/0012-node-kind-is-a-closed-set-of-five.md +31 -0
  45. package/docs/adr/0013-the-diagram-renderer-is-pure.md +23 -0
  46. package/docs/adr/0014-reading-a-node-hides-its-generated-diagram.md +29 -0
  47. package/docs/adr/0015-a-node-id-links-to-its-issue-through-an-anchor.md +37 -0
  48. package/docs/adr/0016-the-map-root-carries-no-status-of-its-own.md +21 -0
  49. package/docs/adr/0017-call-impeccable-copy-only-its-questions.md +13 -0
  50. package/docs/adr/index.md +17 -0
  51. package/docs/config-reference.md +572 -0
  52. package/docs/dev-setups.md +164 -0
  53. package/docs/migrate-0.8.md +28 -0
  54. package/docs/playwriter-bridge-windows.md +15 -0
  55. package/docs/terminal-workspace-keys.md +137 -0
  56. package/docs/terminal-workspace.md +1780 -0
  57. package/docs/workflows.md +309 -0
  58. package/hooks/ac-writing-style-reminder.sh +86 -0
  59. package/hooks/context-pressure.sh +583 -0
  60. package/hooks/hooks.json +50 -0
  61. package/hooks/session-start-handoff.sh +165 -0
  62. package/hooks/session-start.sh +214 -0
  63. package/orchestrator/AGENTS.md +532 -0
  64. package/output-styles/ac-writing-style.md +286 -0
  65. package/package.json +50 -4
  66. package/pi/agents/frontend-verifier.md +357 -0
  67. package/pi/agents/task-checker.md +348 -0
  68. package/pi/agents/task-executor.md +160 -0
  69. package/pi/agents/test-writer.md +154 -0
  70. package/pi/extension.ts +723 -0
  71. package/profiles/fullstack-python.yaml +29 -0
  72. package/profiles/node-typescript.yaml +23 -0
  73. package/profiles/python.yaml +24 -0
  74. package/skills/archive/SKILL.md +160 -0
  75. package/skills/bootstrap/SKILL.md +109 -0
  76. package/skills/bridge/HANDOFF.md +93 -0
  77. package/skills/bridge/SKILL-VM.md +292 -0
  78. package/skills/bridge/SKILL-WINDOWS.md +526 -0
  79. package/skills/bridge/SKILL.md +121 -0
  80. package/skills/browser-verify/SKILL.md +229 -0
  81. package/skills/code-review/SKILL.md +44 -0
  82. package/skills/commit/SKILL.md +106 -0
  83. package/skills/compact-and-continue/SKILL.md +241 -0
  84. package/skills/explore/SKILL.md +149 -0
  85. package/skills/grilling/SKILL.md +134 -0
  86. package/skills/handoff/SKILL.md +250 -0
  87. package/skills/handoff/launch-herdr.md +197 -0
  88. package/skills/handoff/launch-without-herdr.md +30 -0
  89. package/skills/handoff/lead-check.md +59 -0
  90. package/skills/handoff/outcomes.md +162 -0
  91. package/skills/handoff/running-agent.md +50 -0
  92. package/skills/handoff/watch.md +115 -0
  93. package/skills/host/SKILL.md +247 -0
  94. package/skills/host/detect-host.sh +297 -0
  95. package/skills/host/herdr.md +79 -0
  96. package/skills/host/install-herdr-block.sh +467 -0
  97. package/skills/host/install-orca.sh +512 -0
  98. package/skills/host/notes.md +97 -0
  99. package/skills/host/orca.md +160 -0
  100. package/skills/host/questions.md +163 -0
  101. package/skills/host/report.md +42 -0
  102. package/skills/implement/SKILL.md +230 -0
  103. package/skills/implement/decision-log.md +62 -0
  104. package/skills/implement/design-pass.md +32 -0
  105. package/skills/implement/relaxed-tdd.md +28 -0
  106. package/skills/map/SKILL.md +250 -0
  107. package/skills/map/frontend.md +227 -0
  108. package/skills/map/trackers/files.md +74 -0
  109. package/skills/map/trackers/github.md +180 -0
  110. package/skills/map/visuals.md +387 -0
  111. package/skills/new-worktree/SKILL.md +155 -0
  112. package/skills/new-worktree/detect-orchestrator.sh +328 -0
  113. package/skills/new-worktree/detect-orchestrator.test.sh +894 -0
  114. package/skills/new-worktree/herdr.md +84 -0
  115. package/skills/new-worktree/orca.md +101 -0
  116. package/skills/pre-commit-review/SKILL.md +242 -0
  117. package/skills/pre-commit-review/auto-fix.md +19 -0
  118. package/skills/pre-commit-review/interactive.md +35 -0
  119. package/skills/quick-fix/SKILL.md +125 -0
  120. package/skills/record-context/SKILL.md +106 -0
  121. package/skills/setup/SKILL.md +250 -0
  122. package/skills/setup/browser.md +123 -0
  123. package/skills/setup/design-review.md +97 -0
  124. package/skills/setup/first-run.md +114 -0
  125. package/skills/setup/report.md +68 -0
  126. package/skills/setup/row-fixes.md +196 -0
  127. package/skills/setup/terminal-workspace.md +153 -0
  128. package/skills/sync/SKILL.md +171 -0
  129. package/skills/teardown-worktree/SKILL.md +245 -0
  130. package/skills/teardown-worktree/detector.md +39 -0
  131. package/skills/teardown-worktree/herdr.md +113 -0
  132. package/skills/teardown-worktree/orca.md +61 -0
  133. package/skills/terminal-workspace/SKILL.md +249 -0
  134. package/skills/terminal-workspace/components.md +36 -0
  135. package/skills/terminal-workspace/config-choices.md +69 -0
  136. package/skills/terminal-workspace/emulator-keys.md +48 -0
  137. package/skills/terminal-workspace/headless-clipboard.md +175 -0
  138. package/skills/terminal-workspace/turn-off-and-update.md +104 -0
  139. package/skills/test-conventions/SKILL.md +59 -0
  140. package/skills/visual-docs/SKILL.md +246 -0
  141. package/skills/writing/SKILL.md +231 -0
  142. package/skills/writing/examples.md +153 -0
  143. package/skills/writing/vocabulary.md +96 -0
@@ -0,0 +1,450 @@
1
+ # doctor.ps1 – Automated Playwriter bridge diagnosis (Windows side).
2
+ #
3
+ # Runs nine checks and prints a colour-coded table with one-line remediation
4
+ # per red row. When a red row implies VM-side action, emits a paste-ready
5
+ # handoff block (see bridge/HANDOFF.md) that the user can hand to a coding
6
+ # agent on the VM.
7
+ #
8
+ # Exit 0 = all green, 1 = any red.
9
+
10
+ param(
11
+ [Parameter(Mandatory = $false)]
12
+ [int]$CurlTimeoutSeconds = 3
13
+ )
14
+
15
+ $ErrorActionPreference = 'Continue'
16
+
17
+ $logDir = Join-Path $env:LOCALAPPDATA 'playwriter-bridge'
18
+
19
+ $results = @()
20
+ $handoffs = @()
21
+
22
+ function Add-Result {
23
+ param(
24
+ [string]$Check,
25
+ [string]$Status, # 'green', 'amber', 'red'
26
+ [string]$Detail,
27
+ [string]$Remedy = ''
28
+ )
29
+ $script:results += [pscustomobject]@{
30
+ Check = $Check
31
+ Status = $Status
32
+ Detail = $Detail
33
+ Remedy = $Remedy
34
+ }
35
+ }
36
+
37
+ # ---- Check 1: scheduled tasks ---------------------------------------------
38
+
39
+ $tasks = Get-ScheduledTask -TaskName 'Playwriter-*' -ErrorAction SilentlyContinue
40
+ if (-not $tasks) {
41
+ Add-Result 'Scheduled tasks' 'red' 'no Playwriter-* tasks registered' `
42
+ 'Run register-tasks.ps1 from the bridge directory.'
43
+ } else {
44
+ # Split failures from soft states. Report whichever is worse.
45
+ # - Running: trust the scheduler. LastTaskResult is the *previous*
46
+ # run's result and can legitimately be a non-zero HRESULT for
47
+ # long-running tasks while they are actually healthy, so ignore it.
48
+ # - Ready + benign last-result: task exited cleanly (including via
49
+ # Stop-ScheduledTask). Expected state is Running, so flag as amber
50
+ # so the user knows to restart it, not as red.
51
+ # - Ready + non-benign last-result: the task script crashed or hit
52
+ # one of our own fatal exit codes (11=auth, 12=host-key from
53
+ # tunnel.ps1 - a stuck port no longer exits, it keeps retrying).
54
+ # Flag as red.
55
+ # - Any other state (Disabled etc): flag as red.
56
+ #
57
+ # Benign last-result codes for a Ready task:
58
+ # 0 S_OK (task finished successfully)
59
+ # 267009 0x41301 SCHED_S_TASK_RUNNING
60
+ # 267011 0x41303 SCHED_S_TASK_HAS_NOT_RUN
61
+ # 267014 0x41306 SCHED_S_TASK_TERMINATED (user stopped it)
62
+ $benignResults = @(0, 267009, 267011, 267014)
63
+ $red = @()
64
+ $amber = @()
65
+ foreach ($t in $tasks) {
66
+ if ($t.State -eq 'Running') { continue }
67
+
68
+ if ($t.State -ne 'Ready') {
69
+ $red += "$($t.TaskName) state=$($t.State)"
70
+ continue
71
+ }
72
+
73
+ $info = Get-ScheduledTaskInfo -TaskName $t.TaskName -ErrorAction SilentlyContinue
74
+ $result = if ($info) { [int]$info.LastTaskResult } else { $null }
75
+ if ($null -ne $result -and $benignResults -notcontains $result) {
76
+ $red += "$($t.TaskName) state=Ready LastTaskResult=$result"
77
+ } else {
78
+ $amber += "$($t.TaskName) state=Ready (stopped cleanly, expected Running)"
79
+ }
80
+ }
81
+ if ($red.Count -gt 0) {
82
+ Add-Result 'Scheduled tasks' 'red' ($red -join '; ') `
83
+ 'Start-ScheduledTask on the failing tasks, or run stop.ps1 then register-tasks.ps1 fresh.'
84
+ } elseif ($amber.Count -gt 0) {
85
+ Add-Result 'Scheduled tasks' 'amber' ($amber -join '; ') `
86
+ 'Task stopped cleanly. Start-ScheduledTask to bring it back up.'
87
+ } else {
88
+ Add-Result 'Scheduled tasks' 'green' "$($tasks.Count) task(s), all Running"
89
+ }
90
+ }
91
+
92
+ # ---- Check 2: port 19988 listener -----------------------------------------
93
+
94
+ $listener = Get-NetTCPConnection -LocalPort 19988 -State Listen -ErrorAction SilentlyContinue
95
+ if (-not $listener) {
96
+ Add-Result 'Relay listening on 19988' 'red' 'nothing listening on 127.0.0.1:19988' `
97
+ 'The relay task is not up. Run stop.ps1 then Start-ScheduledTask Playwriter-Relay.'
98
+ } else {
99
+ Add-Result 'Relay listening on 19988' 'green' "PID $($listener.OwningProcess -join ',') listening"
100
+ }
101
+
102
+ # ---- Check 3: /json/version -----------------------------------------------
103
+
104
+ $versionOk = $false
105
+ if ($listener) {
106
+ try {
107
+ $r = Invoke-WebRequest -Uri 'http://127.0.0.1:19988/json/version' `
108
+ -TimeoutSec $CurlTimeoutSeconds -UseBasicParsing -ErrorAction Stop
109
+ if ($r.StatusCode -eq 200) {
110
+ $versionOk = $true
111
+ Add-Result 'Relay /json/version' 'green' "HTTP 200 ($([int]$r.RawContentLength) bytes)"
112
+ } else {
113
+ Add-Result 'Relay /json/version' 'red' "HTTP $($r.StatusCode)" `
114
+ 'Relay port is bound but not responding. Restart Playwriter-Relay.'
115
+ }
116
+ } catch {
117
+ Add-Result 'Relay /json/version' 'red' "request failed: $($_.Exception.Message)" `
118
+ 'Relay is not answering. Restart Playwriter-Relay.'
119
+ }
120
+ } else {
121
+ Add-Result 'Relay /json/version' 'red' 'skipped (no listener)' ''
122
+ }
123
+
124
+ # ---- Check 4: /json/list --------------------------------------------------
125
+
126
+ if ($versionOk) {
127
+ try {
128
+ $r = Invoke-WebRequest -Uri 'http://127.0.0.1:19988/json/list' `
129
+ -TimeoutSec $CurlTimeoutSeconds -UseBasicParsing -ErrorAction Stop
130
+ $body = $r.Content.Trim()
131
+ if ($body -eq '[]' -or $body.Length -lt 3) {
132
+ Add-Result 'Extension armed on a tab' 'amber' 'relay reports no attached tabs' `
133
+ 'Click the Playwriter extension icon on a normal web page in the Playwriter Dev Chrome profile.'
134
+ } else {
135
+ Add-Result 'Extension armed on a tab' 'green' "$(($r.Content | ConvertFrom-Json).Count) tab(s) attached"
136
+ }
137
+ } catch {
138
+ Add-Result 'Extension armed on a tab' 'amber' "list check failed: $($_.Exception.Message)" ''
139
+ }
140
+ } else {
141
+ Add-Result 'Extension armed on a tab' 'amber' 'skipped (version check failed)' ''
142
+ }
143
+
144
+ # ---- Check 4b: the document opener ----------------------------------------
145
+ #
146
+ # The opener is what puts a page in the default browser here. It is a separate
147
+ # service from the relay on purpose - it needs no tab armed and no extension -
148
+ # so it gets its own rows rather than being folded into the bridge's.
149
+
150
+ $openerToken = Join-Path $env:LOCALAPPDATA 'playwriter-bridge\opener.token'
151
+ $openerListener = Get-NetTCPConnection -LocalPort 19989 -State Listen -ErrorAction SilentlyContinue
152
+ if (-not $openerListener) {
153
+ Add-Result 'Opener listening on 19989' 'red' 'nothing listening on 127.0.0.1:19989' `
154
+ 'The opener task is not up. Run Start-ScheduledTask Playwriter-Opener, then check opener-supervisor.log.'
155
+ } else {
156
+ Add-Result 'Opener listening on 19989' 'green' "PID $($openerListener.OwningProcess -join ',') listening"
157
+ }
158
+
159
+ if (-not (Test-Path $openerToken)) {
160
+ Add-Result 'Opener token' 'red' 'no token file' `
161
+ 'Run register-tasks.ps1 to generate one and push it to each VM.'
162
+ } else {
163
+ # A token here is only half of it: the dev machine needs the same bytes, and
164
+ # nothing on this side can see whether it has them. Say so rather than
165
+ # calling it green and leaving a VM that gets 401 on every request.
166
+ Add-Result 'Opener token' 'green' 'present here (each VM needs the same copy at ~/.config/acstack/opener.token)'
167
+ }
168
+
169
+ if ($openerListener -and (Test-Path $openerToken)) {
170
+ try {
171
+ $tok = (Get-Content $openerToken -Raw).Trim()
172
+ $r = Invoke-WebRequest -Uri 'http://127.0.0.1:19989/health' `
173
+ -Headers @{ 'X-ACStack-Token' = $tok } `
174
+ -TimeoutSec $CurlTimeoutSeconds -UseBasicParsing -ErrorAction Stop
175
+ $h = $r.Content | ConvertFrom-Json
176
+ $mermaid = if ($h.mermaid) { 'mermaid cached' } else { 'no mermaid cached yet (first push will send it)' }
177
+ Add-Result 'Opener /health' 'green' "HTTP 200, $($h.docs) document(s) held, $mermaid"
178
+ } catch {
179
+ Add-Result 'Opener /health' 'red' "request failed: $($_.Exception.Message)" `
180
+ 'The port is open but the service is not answering. Restart Playwriter-Opener and read opener.log.'
181
+ }
182
+ } else {
183
+ Add-Result 'Opener /health' 'amber' 'skipped (no listener or no token)' ''
184
+ }
185
+
186
+ # ---- Check 5: tunnel log signatures ---------------------------------------
187
+
188
+ # tunnel.ps1 names both its log and its marker tunnel-<host> for the bridge's
189
+ # 19988 and tunnel-<host>-<port> for anything else. Stripping only the prefix
190
+ # would leave the host as '<host>-19989', not a machine anyone can ssh to, and
191
+ # would lose the one detail that says which port is wedged.
192
+ function Split-TunnelName {
193
+ param([string]$BaseName)
194
+ # Only a port register-tasks.ps1 registers its own tunnel task for can
195
+ # appear as a suffix, and 19988 never gets one - so the opener's 19989 is
196
+ # the whole list. Add to it if a third port is ever registered there.
197
+ $suffixPorts = @(19989)
198
+ $raw = ($BaseName -replace '^tunnel-', '')
199
+ $name = $raw
200
+ $port = 19988
201
+ if ($raw -match '^(.+)-(\d+)$' -and $suffixPorts -contains [int]$Matches[2]) {
202
+ $name = $Matches[1]
203
+ $port = [int]$Matches[2]
204
+ }
205
+ return [pscustomobject]@{ Name = $name; Port = $port; Raw = $raw }
206
+ }
207
+
208
+ $stuckHosts = @()
209
+ $authHosts = @()
210
+ $hostKeyHosts = @()
211
+ $retryHosts = @()
212
+
213
+ if (Test-Path $logDir) {
214
+ $stuckFiles = Get-ChildItem -Path $logDir -Filter 'tunnel-*.stuck' -ErrorAction SilentlyContinue
215
+ foreach ($f in $stuckFiles) {
216
+ $content = Get-Content $f.FullName -Raw
217
+ # The split is deliberately narrow: host names end in digits all the
218
+ # time, and on a bare \d+ the laptop 'alt-p14-12' would become host
219
+ # 'alt-p14' on port 12, which vm-free-port.sh refuses.
220
+ $split = Split-TunnelName -BaseName $f.BaseName
221
+ # .Name, never .Raw: an opener marker's Raw is 'alt-p14-19989', which is
222
+ # not a machine anyone can ssh to, and the auth and host-key handoffs
223
+ # below tell a reader to run commands on it.
224
+ $target = $split.Name
225
+ $stuckHost = $split.Name
226
+ $markerPort = $split.Port
227
+ $suffix = if ($markerPort -eq 19988) { '' } else { "-$markerPort" }
228
+ $entry = [pscustomobject]@{
229
+ Name = $stuckHost
230
+ Port = $markerPort
231
+ Log = "tunnel-$stuckHost$suffix.log"
232
+ Marker = $f.FullName
233
+ Label = "$stuckHost`:$markerPort"
234
+ }
235
+ if ($content -match 'remote port forwarding failed') { $stuckHosts += $entry }
236
+ elseif ($content -match 'Permission denied') { $authHosts += $target }
237
+ elseif ($content -match 'Host key verification') { $hostKeyHosts += $target }
238
+ else { $stuckHosts += $entry } # unknown fatal - group with stuck for visibility
239
+ }
240
+
241
+ # The marker only lands after 8 consecutive stuck attempts, which is ~11 min
242
+ # of a down bridge before anything is written. tunnel.ps1 logs a
243
+ # [stuck-retry] line on every one of those attempts, so the log says it
244
+ # first - and a row keyed only on tunnel-*.stuck cannot see it. That gap is
245
+ # not hypothetical: during the orphan episode on the laptop, tunnel-*.log
246
+ # carried a stuck-retry every 30 s while this row reported green.
247
+ $logFiles = Get-ChildItem -Path $logDir -Filter 'tunnel-*.log' -ErrorAction SilentlyContinue
248
+ foreach ($lf in $logFiles) {
249
+ $split = Split-TunnelName -BaseName $lf.BaseName
250
+ # A marker for the same host and port already reports this, in red.
251
+ if ($stuckHosts | Where-Object { $_.Name -eq $split.Name -and $_.Port -eq $split.Port }) { continue }
252
+ $lastRetry = $null
253
+ $lastState = ''
254
+ foreach ($line in (Get-Content $lf.FullName -Tail 200 -ErrorAction SilentlyContinue)) {
255
+ if ($line -notmatch '^\[([^\]]+)\] \[(stuck-retry|start-failed)\]') { continue }
256
+ # Write-Log's format in tunnel.ps1, fixed. A truncated or
257
+ # half-written line parses as nothing and is skipped, rather than
258
+ # throwing and taking the whole check down with it.
259
+ $stamp = [datetime]::MinValue
260
+ if ([datetime]::TryParseExact($Matches[1], 'yyyy-MM-dd HH:mm:ss',
261
+ [Globalization.CultureInfo]::InvariantCulture,
262
+ [Globalization.DateTimeStyles]::None, [ref]$stamp)) {
263
+ $lastRetry = $stamp
264
+ $lastState = $Matches[2]
265
+ }
266
+ }
267
+ # A log keeps its stuck-retry lines long after the port clears, so
268
+ # without a horizon this row would stay amber forever on the strength of
269
+ # a wedge that resolved days ago. 300 s is two of the capped 120 s waits
270
+ # plus slack, so a tunnel still in its backoff is inside the window.
271
+ if ($null -ne $lastRetry -and ((Get-Date) - $lastRetry).TotalSeconds -le 300) {
272
+ $retryHosts += [pscustomobject]@{
273
+ Name = $split.Name
274
+ Port = $split.Port
275
+ State = $lastState
276
+ Label = "$($split.Name)`:$($split.Port) [$lastState]"
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ if ($stuckHosts.Count -eq 0 -and $authHosts.Count -eq 0 -and $hostKeyHosts.Count -eq 0) {
283
+ if ($retryHosts.Count -gt 0) {
284
+ # Amber, not red: the port is held right now, but a tunnel two attempts
285
+ # into its backoff may well bind on the next one. Red is reserved for a
286
+ # marker, where the retries have already outlasted the VM's reaper and a
287
+ # human has to act. Reporting both the same way either cries wolf or
288
+ # buries the marker.
289
+ $who = ($retryHosts | ForEach-Object { $_.Label }) -join ', '
290
+ $free = ($retryHosts | ForEach-Object { "bash ~/.claude/acstack/bin/vm-free-port.sh --port $($_.Port)" } |
291
+ Select-Object -Unique) -join ' ; '
292
+ # The window trails five minutes, so the row can still be amber after the
293
+ # tunnel has quietly rebound. Say that the clearer is safe anyway: it
294
+ # probes the port first and refuses a forward that still answers.
295
+ Add-Result 'Tunnel logs' 'amber' "tunnel retrying in the last 5 min: $who (no marker yet)" `
296
+ "stuck-retry: the VM still holds the forward - run on that VM: $free. Safe to run even if the tunnel has already rebound: vm-free-port.sh refuses a live forward instead of killing it. start-failed: ssh never launched on this laptop - kill any orphaned ssh.exe holding the tunnel log files."
297
+ } else {
298
+ Add-Result 'Tunnel logs' 'green' 'no stuck markers, no tunnel retrying a held port'
299
+ }
300
+ } else {
301
+ $parts = @()
302
+ if ($stuckHosts.Count -gt 0) { $parts += "stuck port: $(($stuckHosts | ForEach-Object { $_.Label }) -join ', ')" }
303
+ if ($authHosts.Count -gt 0) { $parts += "auth denied: $($authHosts -join ', ')" }
304
+ if ($hostKeyHosts.Count -gt 0) { $parts += "host key: $($hostKeyHosts -join ', ')" }
305
+ # A marker on VM1 must not hide a live retry on VM2. There is one row for
306
+ # every tunnel, so the retries have to ride along with the red verdict
307
+ # rather than living only in the branch where nothing else went wrong.
308
+ if ($retryHosts.Count -gt 0) { $parts += "retrying now: $(($retryHosts | ForEach-Object { $_.Label }) -join ', ')" }
309
+ $remedy = 'See handoff blocks below.'
310
+ Add-Result 'Tunnel logs' 'red' ($parts -join '; ') $remedy
311
+
312
+ foreach ($h in $stuckHosts) {
313
+ # The VM agent reading this has no other context, so every line has to
314
+ # name the port that is actually wedged. vm-free-port.sh defaults to
315
+ # the bridge's port, which is the wrong port whenever the marker was
316
+ # the opener's, and a bare call would send it to free the wrong socket.
317
+ $hn = $h.Name
318
+ $hp = $h.Port
319
+ $tail = ''
320
+ if (Test-Path $h.Marker) { $tail = (Get-Content $h.Marker -Raw).Trim() }
321
+ $handoffs += @"
322
+ --- BEGIN WINDOWS-SIDE HANDOFF (to VM agent on $hn) ---
323
+ Context: the Playwriter bridge tunnel to $hn is stuck on port $hp. $($h.Log)
324
+ shows "remote port forwarding failed for listen port $hp", meaning something
325
+ on the VM already holds that port. The tunnel task is still retrying, so it
326
+ will bind on its own once the port is free. Marker contents:
327
+
328
+ $tail
329
+
330
+ Run on ${hn}:
331
+ bash ~/.claude/acstack/bin/vm-free-port.sh --port $hp
332
+
333
+ Expected result:
334
+ ss -lnt 'sport = :$hp' is empty, or vm-free-port.sh refuses and tells
335
+ you the port is held by your own interactive session (in which case exit
336
+ that session and retry).
337
+
338
+ Report back:
339
+ The exit status of vm-free-port.sh and the final ss output.
340
+ --- END WINDOWS-SIDE HANDOFF ---
341
+ "@
342
+ }
343
+ foreach ($h in $authHosts) {
344
+ $handoffs += @"
345
+ --- BEGIN WINDOWS-SIDE HANDOFF (to VM agent on $h) ---
346
+ Context: the Playwriter bridge tunnel to $h gets "Permission denied
347
+ (publickey)". Either the Windows ssh-agent has no key loaded, or the
348
+ matching public key is not in authorized_keys on the VM.
349
+
350
+ Run on ${h}:
351
+ ssh-keygen -l -f ~/.ssh/authorized_keys
352
+
353
+ Then:
354
+ Ask the Windows side for its public key fingerprint (ssh-add -l on the
355
+ laptop) and confirm it matches one of the entries above.
356
+
357
+ Expected result:
358
+ The Windows laptop's public key fingerprint appears in
359
+ ~/.ssh/authorized_keys. If not, append it.
360
+
361
+ Report back:
362
+ Whether the expected key was present, or confirmation that you appended
363
+ the correct one.
364
+ --- END WINDOWS-SIDE HANDOFF ---
365
+ "@
366
+ }
367
+ foreach ($h in $hostKeyHosts) {
368
+ $handoffs += @"
369
+ --- BEGIN WINDOWS-SIDE HANDOFF (to VM agent on $h) ---
370
+ Context: the Playwriter bridge tunnel to $h fails with "Host key
371
+ verification failed". The VM's host key changed, or the Windows
372
+ known_hosts is stale.
373
+
374
+ Run on ${h}:
375
+ ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub
376
+
377
+ Expected result:
378
+ Print the VM's current ed25519 host key fingerprint.
379
+
380
+ Report back:
381
+ The fingerprint. The Windows side will then clear the stale entry
382
+ (ssh-keygen -R $h) and retry.
383
+ --- END WINDOWS-SIDE HANDOFF ---
384
+ "@
385
+ }
386
+ }
387
+
388
+ # ---- Check 6: ssh-agent service ------------------------------------------
389
+
390
+ $svc = Get-Service ssh-agent -ErrorAction SilentlyContinue
391
+ if (-not $svc) {
392
+ Add-Result 'ssh-agent service' 'red' 'service not installed' `
393
+ 'Enable the Windows OpenSSH Client optional feature, then Set-Service ssh-agent -StartupType Automatic.'
394
+ } elseif ($svc.Status -ne 'Running') {
395
+ Add-Result 'ssh-agent service' 'red' "service is $($svc.Status)" `
396
+ 'Start-Service ssh-agent (may require elevation).'
397
+ } else {
398
+ $keys = & ssh-add -l 2>&1
399
+ $code = $LASTEXITCODE
400
+ if ($code -ne 0) {
401
+ Add-Result 'ssh-agent keys' 'red' 'no keys loaded (ssh-add -l exit != 0)' `
402
+ 'Run: ssh-add $env:USERPROFILE\.ssh\id_ed25519'
403
+ } else {
404
+ $count = ($keys | Measure-Object).Count
405
+ Add-Result 'ssh-agent keys' 'green' "$count key(s) loaded"
406
+ }
407
+ }
408
+
409
+ # ---- Output ---------------------------------------------------------------
410
+
411
+ Write-Host ""
412
+ Write-Host "Playwriter bridge – doctor report"
413
+ Write-Host "========================================="
414
+
415
+ foreach ($r in $results) {
416
+ $color = switch ($r.Status) {
417
+ 'green' { 'Green' }
418
+ 'amber' { 'Yellow' }
419
+ 'red' { 'Red' }
420
+ default { 'Gray' }
421
+ }
422
+ $tag = switch ($r.Status) {
423
+ 'green' { '[OK] ' }
424
+ 'amber' { '[WARN]' }
425
+ 'red' { '[FAIL]' }
426
+ default { '[??] ' }
427
+ }
428
+ Write-Host ("{0} {1,-32} {2}" -f $tag, $r.Check, $r.Detail) -ForegroundColor $color
429
+ if ($r.Remedy) {
430
+ Write-Host (" -> {0}" -f $r.Remedy) -ForegroundColor DarkGray
431
+ }
432
+ }
433
+
434
+ if ($handoffs.Count -gt 0) {
435
+ Write-Host ""
436
+ Write-Host "VM-side action required. Paste the block(s) below to a coding agent on the VM."
437
+ Write-Host "----------------------------------------------------------------------------"
438
+ foreach ($h in $handoffs) {
439
+ Write-Host ""
440
+ Write-Host $h
441
+ }
442
+ Write-Host ""
443
+ }
444
+
445
+ # Wrap in @(...) so a single scalar pipeline result still has a reliable
446
+ # .Count. PowerShell's scalar-vs-array collapse has bitten this check
447
+ # before – @() makes the count deterministic.
448
+ $redCount = @($results | Where-Object { $_.Status -eq 'red' }).Count
449
+ if ($redCount -gt 0) { exit 1 }
450
+ exit 0
@@ -0,0 +1,131 @@
1
+ // launcher-src.cs – tiny WindowsApplication shim that spawns a child process
2
+ // with CREATE_NO_WINDOW so no console is ever allocated.
3
+ //
4
+ // Compiled by build-launcher.ps1 to launcher.exe via PowerShell's Add-Type
5
+ // with -OutputType WindowsApplication. Compiling as a WindowsApplication is
6
+ // the load-bearing bit – a ConsoleApplication launcher would itself allocate
7
+ // a console window on spawn, defeating the purpose.
8
+ //
9
+ // Why this exists: on Windows 11 22H2+ the default terminal host is Windows
10
+ // Terminal. When PowerShell calls GetConsoleWindow() under WT, it gets a
11
+ // ConPTY proxy handle, not the actual WT window – so ShowWindow() calls on
12
+ // that handle do nothing visible. Running PowerShell under this launcher
13
+ // bypasses the problem entirely: CREATE_NO_WINDOW means no console is
14
+ // attached at all, so there is no window to hide.
15
+ //
16
+ // The launcher also acts as the real parent of the child process. It waits
17
+ // for the child to exit and propagates its exit code, and on termination
18
+ // (Stop-ScheduledTask, Ctrl+C, logoff) it walks the process tree via WMI
19
+ // and kills every descendant before exiting. Without this, Stop-ScheduledTask
20
+ // orphans the PowerShell child and the bridge keeps running.
21
+ //
22
+ // Usage: launcher.exe <child-exe-path> [args...]
23
+
24
+ using System;
25
+ using System.Diagnostics;
26
+ using System.Management;
27
+ using System.Text;
28
+
29
+ class Launcher {
30
+ static Process child;
31
+
32
+ static int Main(string[] args) {
33
+ if (args.Length == 0) return 1;
34
+
35
+ var psi = new ProcessStartInfo();
36
+ psi.FileName = args[0];
37
+ psi.UseShellExecute = false;
38
+ psi.CreateNoWindow = true;
39
+ psi.WindowStyle = ProcessWindowStyle.Hidden;
40
+
41
+ // Re-join remaining args as a single Arguments string. Caller is
42
+ // expected to pre-quote any paths with spaces.
43
+ var sb = new StringBuilder();
44
+ for (int i = 1; i < args.Length; i++) {
45
+ if (sb.Length > 0) sb.Append(' ');
46
+ sb.Append(args[i]);
47
+ }
48
+ psi.Arguments = sb.ToString();
49
+
50
+ AppDomain.CurrentDomain.ProcessExit += (s, e) => KillChildTree();
51
+ Console.CancelKeyPress += (s, e) => { KillChildTree(); Environment.Exit(130); };
52
+
53
+ try {
54
+ child = Process.Start(psi);
55
+ } catch {
56
+ return 2;
57
+ }
58
+ // Process.Start can return null when no new process was actually
59
+ // started (rare with UseShellExecute=false, but documented). Without
60
+ // this guard, WaitForExit below would NRE.
61
+ if (child == null) return 2;
62
+
63
+ try {
64
+ child.WaitForExit();
65
+ return child.ExitCode;
66
+ } catch {
67
+ KillChildTree();
68
+ return 3;
69
+ }
70
+ }
71
+
72
+ // Walk Win32_Process.ParentProcessId to find every descendant of the
73
+ // child, then kill them leaves-first. We use WMI because our Add-Type
74
+ // compile targets .NET Framework 4.x, which lacks
75
+ // Process.Kill(entireProcessTree: true).
76
+ //
77
+ // PID reuse guard: Windows recycles PIDs aggressively. Between a WMI
78
+ // child-list query and Process.Kill(), a child can exit and its PID
79
+ // be reassigned to an unrelated process. We capture CreationDate per
80
+ // WMI row and verify it still matches before killing, so a recycled
81
+ // PID pointing at an unrelated process is skipped.
82
+ static void KillChildTree() {
83
+ if (child == null) return;
84
+ try {
85
+ if (child.HasExited) return;
86
+ } catch { return; }
87
+
88
+ // Descendants first (leaves-first), each PID-reuse guarded via WMI
89
+ // CreationDate. The top-level child is safe to kill directly: we
90
+ // hold the Process handle from Process.Start, so its PID cannot
91
+ // have been reassigned while we held it open.
92
+ KillDescendants(child.Id);
93
+ try { child.Kill(); } catch { }
94
+ }
95
+
96
+ static void KillDescendants(int pid) {
97
+ try {
98
+ var query = "SELECT ProcessId, CreationDate FROM Win32_Process WHERE ParentProcessId = " + pid;
99
+ using (var searcher = new ManagementObjectSearcher(query))
100
+ using (var results = searcher.Get()) {
101
+ foreach (ManagementObject mo in results) {
102
+ int childPid = Convert.ToInt32(mo["ProcessId"]);
103
+ string childCreated = mo["CreationDate"] as string;
104
+ KillDescendants(childPid);
105
+ KillIfSame(childPid, childCreated);
106
+ }
107
+ }
108
+ } catch { }
109
+ }
110
+
111
+ // Kill pid only if its current CreationDate still matches the one we
112
+ // captured from the WMI walk – otherwise the PID has been recycled.
113
+ static void KillIfSame(int pid, string expectedCreated) {
114
+ if (expectedCreated == null) return;
115
+ try {
116
+ var query = "SELECT CreationDate FROM Win32_Process WHERE ProcessId = " + pid;
117
+ using (var searcher = new ManagementObjectSearcher(query))
118
+ using (var results = searcher.Get()) {
119
+ foreach (ManagementObject mo in results) {
120
+ string now = mo["CreationDate"] as string;
121
+ if (now != expectedCreated) return;
122
+ try {
123
+ var p = Process.GetProcessById(pid);
124
+ p.Kill();
125
+ } catch { }
126
+ return;
127
+ }
128
+ }
129
+ } catch { }
130
+ }
131
+ }