acstack 0.0.0-stage → 0.47.0

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 +329 -0
  27. package/assets/terminal-workspace/setup.sh +4317 -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 +1774 -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,271 @@
1
+ # sync.ps1 - Reconcile the deployed Playwriter bridge with the plugin cache.
2
+ #
3
+ # The bridge runs as OS scheduled tasks from %USERPROFILE%\playwriter-bridge\,
4
+ # a location that does not track the plugin version. A plugin auto-update
5
+ # refreshes the cache but never reaches those deployed copies, and a running
6
+ # tunnel.ps1 will not pick up a new script without a restart. This script
7
+ # closes that gap: it is invoked by the ACStack SessionStart hook, compares
8
+ # each deployed script against the cache by content hash, copies the ones that
9
+ # changed, rebuilds launcher.exe when its source changed, and restarts only the
10
+ # affected tasks.
11
+ #
12
+ # It is a quiet no-op unless the bridge is actually installed (deploy dir
13
+ # present AND at least one Playwriter-* task registered), so it costs nothing
14
+ # for sessions and machines that never use the bridge.
15
+ #
16
+ # Runs as the current user, non-elevated - same rights the scheduled tasks use.
17
+
18
+ param(
19
+ [Parameter(Mandatory = $true)]
20
+ [string]$PluginRoot,
21
+
22
+ [Parameter(Mandatory = $false)]
23
+ [string]$ScriptsDir = (Join-Path $env:USERPROFILE "playwriter-bridge")
24
+ )
25
+
26
+ $ErrorActionPreference = 'Continue'
27
+
28
+ # Relay, tunnel and launcher only exist on Windows.
29
+ if ($env:OS -ne 'Windows_NT') { exit 0 }
30
+
31
+ $cacheDir = Join-Path $PluginRoot 'assets\playwriter-bridge'
32
+ if (-not (Test-Path $cacheDir)) { exit 0 }
33
+
34
+ # Gate: only act when the bridge is installed.
35
+ if (-not (Test-Path $ScriptsDir)) { exit 0 }
36
+ $tasks = @(Get-ScheduledTask -TaskName 'Playwriter-*' -ErrorAction SilentlyContinue)
37
+ if ($tasks.Count -eq 0) { exit 0 }
38
+
39
+ # Windows-side runtime files. vm-free-port.sh is VM-only; sync.ps1 itself runs
40
+ # from the cache and is never deployed.
41
+ $files = @(
42
+ 'launcher-src.cs',
43
+ 'build-launcher.ps1',
44
+ 'relay.ps1',
45
+ 'tunnel.ps1',
46
+ 'register-tasks.ps1',
47
+ 'stop.ps1',
48
+ 'doctor.ps1',
49
+ 'opener.ps1',
50
+ 'opener.js'
51
+ )
52
+
53
+ function Get-FileHashOrNull($path) {
54
+ if (-not (Test-Path $path)) { return $null }
55
+ return (Get-FileHash -Path $path -Algorithm SHA256 -ErrorAction SilentlyContinue).Hash
56
+ }
57
+
58
+ # Stop-ScheduledTask returns as soon as the stop is queued, and the scheduler
59
+ # force-terminates launcher.exe rather than letting its ProcessExit handler run,
60
+ # so the launcher's WMI descendant kill never fires. The child PowerShell - and
61
+ # the node.exe opener.ps1/relay.ps1 spawned, or the ssh.exe tunnel.ps1 spawned -
62
+ # survives as an orphan still holding its port. The task started next can then
63
+ # never bind, the new script never actually runs, and this script prints
64
+ # "restarted" anyway. Observed on Windows 11 26200: across a changed opener.js,
65
+ # a Stop/Start pair left the same node.exe PID serving 19989.
66
+ #
67
+ # So: request the stop, wait for the instance to really go, then reap what is
68
+ # left. Scoped by the task's own registered argument string so a tunnel task
69
+ # never reaps its sibling on the other port or the other VM. launcher.exe drops
70
+ # the quotes around -File when it builds the child command line, which is why
71
+ # the comparison is de-quoted on both sides.
72
+ function Stop-BridgeTaskTree {
73
+ param([string]$Name)
74
+
75
+ $task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
76
+ $want = if ($task) { ($task.Actions.Arguments -replace '"', '') } else { $null }
77
+
78
+ Stop-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
79
+
80
+ for ($i = 0; $i -lt 20; $i++) {
81
+ $state = (Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue).State
82
+ if ($state -ne 'Running') { break }
83
+ Start-Sleep -Milliseconds 250
84
+ }
85
+
86
+ if (-not $want) { return }
87
+
88
+ $survivors = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
89
+ $_.ProcessId -ne $PID -and $_.CommandLine -and ($_.CommandLine -replace '"', '') -eq $want
90
+ })
91
+ foreach ($p in $survivors) {
92
+ # Children first. Killing the supervisor alone re-orphans the node.exe
93
+ # or ssh.exe it was holding, which is the whole bug this guards against.
94
+ Get-CimInstance Win32_Process -Filter "ParentProcessId=$($p.ProcessId)" -ErrorAction SilentlyContinue |
95
+ ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
96
+ Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
97
+ Write-Host "acstack bridge: reaped surviving $Name process (PID $($p.ProcessId))"
98
+ }
99
+ }
100
+
101
+ $changed = @()
102
+ foreach ($f in $files) {
103
+ $src = Join-Path $cacheDir $f
104
+ if (-not (Test-Path $src)) { continue }
105
+ $dst = Join-Path $ScriptsDir $f
106
+ if ((Get-FileHashOrNull $src) -ne (Get-FileHashOrNull $dst)) { $changed += $f }
107
+ }
108
+
109
+ if ($changed.Count -eq 0) { exit 0 }
110
+
111
+ Write-Host "acstack bridge: $($changed.Count) script(s) changed in the plugin cache: $($changed -join ', ')"
112
+
113
+ # Concurrency guard: two Claude Code sessions can start near-simultaneously and
114
+ # both pass the hash gate above, then race on copy/rebuild/stop-start. A system
115
+ # mutex serializes the mutating section so only one session reconciles at a time.
116
+ # Creation can require elevation in some environments - if that fails we proceed
117
+ # without the guard rather than crashing the hook.
118
+ $mutex = $null
119
+ try {
120
+ $mutex = New-Object System.Threading.Mutex($false, 'Global\acstack-bridge-sync')
121
+ } catch {
122
+ $mutex = $null
123
+ }
124
+ if ($mutex) {
125
+ $haveLock = $false
126
+ try { $haveLock = $mutex.WaitOne(2000) } catch { $haveLock = $false }
127
+ if (-not $haveLock) {
128
+ Write-Host "acstack bridge: another session is already syncing - skipping."
129
+ $mutex.Dispose()
130
+ exit 0
131
+ }
132
+ }
133
+
134
+ # Everything from here mutates the deploy dir, so it runs inside try/finally to
135
+ # guarantee the mutex is released on every exit path (including the FIX A early
136
+ # exit below - PowerShell runs finally even when 'exit' fires inside the try).
137
+ try {
138
+
139
+ # Restart eligibility, snapshotted BEFORE we stop anything. A Running task is
140
+ # restarted so the fix goes live. A Ready task is restarted only if it ended on
141
+ # a failure (e.g. tunnel.ps1 exit 11 on a denied key) - that both applies the
142
+ # fix and recovers
143
+ # it. A Ready task that exited cleanly is left alone: the user stopped it on
144
+ # purpose. Benign last-result codes mirror doctor.ps1 - doctor.ps1 holds the
145
+ # canonical copy of this list; keep both in sync if either changes.
146
+ $benign = @(0, 267009, 267011, 267014)
147
+ $eligible = @{}
148
+ foreach ($t in $tasks) {
149
+ $ok = $false
150
+ if ($t.State -eq 'Running') {
151
+ $ok = $true
152
+ } elseif ($t.State -eq 'Ready') {
153
+ $info = Get-ScheduledTaskInfo -TaskName $t.TaskName -ErrorAction SilentlyContinue
154
+ if ($info -and ($benign -notcontains [int]$info.LastTaskResult)) { $ok = $true }
155
+ }
156
+ $eligible[$t.TaskName] = $ok
157
+ }
158
+
159
+ $anyRunning = @($tasks | Where-Object { $_.State -eq 'Running' }).Count -gt 0
160
+
161
+ # A running task holds launcher.exe open, so it cannot be overwritten. When the
162
+ # launcher source changed, stop everything first to release the binary. Stopping
163
+ # is keyed off intent ($changed): stopping is always safe, and we must release
164
+ # the binary BEFORE the copy regardless of whether the copy later succeeds.
165
+ if (($changed -contains 'launcher-src.cs') -and $anyRunning) {
166
+ foreach ($t in $tasks) { Stop-BridgeTaskTree -Name $t.TaskName }
167
+ Start-Sleep -Milliseconds 500
168
+ }
169
+
170
+ # Copy the changed files into the deploy dir (byte copy preserves the BOM). Track
171
+ # which files ACTUALLY landed in $copied: a locked or permission-denied copy must
172
+ # not silently no-op and then trigger a restart on the stale script (that would
173
+ # re-thrash every session). Restart decisions below derive from $copied, not
174
+ # $changed, so a task whose backing script failed to copy is left alone.
175
+ $copied = @()
176
+ foreach ($f in $changed) {
177
+ try {
178
+ Copy-Item -Path (Join-Path $cacheDir $f) -Destination (Join-Path $ScriptsDir $f) -Force -ErrorAction Stop
179
+ $copied += $f
180
+ } catch {
181
+ Write-Host "acstack bridge: copy FAILED for $f ($($_.Exception.Message)). Leaving its task as-is."
182
+ }
183
+ }
184
+
185
+ # Decide which tasks each SUCCESSFULLY COPIED file forces a restart of:
186
+ # launcher-src.cs -> every task (all run through launcher.exe)
187
+ # relay.ps1 -> Playwriter-Relay
188
+ # opener.ps1 -> Playwriter-Opener
189
+ # opener.js -> Playwriter-Opener (opener.ps1 supervises it, so the
190
+ # supervisor has to be bounced for the new code to run)
191
+ # tunnel.ps1 -> every tunnel task, bridge and opener alike
192
+ # stop.ps1 / doctor.ps1 / build-launcher.ps1 / register-tasks.ps1 are invoked
193
+ # on demand, so a copy is enough - no restart.
194
+ # NOTE: any NEW task-backing script added to $files above must also be wired into
195
+ # this mapping, or it will deploy but never trigger a restart of its task.
196
+ $launcherChanged = $copied -contains 'launcher-src.cs'
197
+ $relayChanged = $copied -contains 'relay.ps1'
198
+ $tunnelChanged = $copied -contains 'tunnel.ps1'
199
+ $openerChanged = ($copied -contains 'opener.ps1') -or ($copied -contains 'opener.js')
200
+
201
+ $affected = New-Object System.Collections.Generic.HashSet[string]
202
+ if ($launcherChanged) {
203
+ foreach ($t in $tasks) { [void]$affected.Add($t.TaskName) }
204
+ } else {
205
+ if ($relayChanged) { [void]$affected.Add('Playwriter-Relay') }
206
+ if ($openerChanged) { [void]$affected.Add('Playwriter-Opener') }
207
+ if ($tunnelChanged) {
208
+ # Both families run tunnel.ps1. 'Playwriter-Tunnel-*' alone would miss
209
+ # the opener's tunnel tasks, which are named Playwriter-OpenerTunnel-*.
210
+ foreach ($t in $tasks) {
211
+ if ($t.TaskName -like 'Playwriter-Tunnel-*' -or $t.TaskName -like 'Playwriter-OpenerTunnel-*') {
212
+ [void]$affected.Add($t.TaskName)
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ $restartList = @($affected | Where-Object { $eligible[$_] })
219
+
220
+ # Rebuild launcher.exe from the freshly copied source. $rebuildOk starts true
221
+ # (true when no rebuild was needed); the catch and a missing output both clear
222
+ # it. If launcher-src.cs failed to copy, $launcherChanged is already false, so
223
+ # no rebuild is attempted and the source is stale - that is also a
224
+ # rebuild-can't-happen case, so we force $rebuildOk false to skip the restart.
225
+ $rebuildOk = $true
226
+ if ($launcherChanged) {
227
+ try {
228
+ & (Join-Path $ScriptsDir 'build-launcher.ps1') | Out-Null
229
+ if (Test-Path (Join-Path $ScriptsDir 'launcher.exe')) {
230
+ Write-Host "acstack bridge: rebuilt launcher.exe"
231
+ } else {
232
+ $rebuildOk = $false
233
+ Write-Host "acstack bridge: launcher rebuild produced no launcher.exe."
234
+ }
235
+ } catch {
236
+ $rebuildOk = $false
237
+ Write-Host "acstack bridge: launcher rebuild FAILED ($($_.Exception.Message))."
238
+ }
239
+ } elseif (($changed -contains 'launcher-src.cs') -and -not ($copied -contains 'launcher-src.cs')) {
240
+ # launcher-src.cs changed but the copy failed: the deployed source is stale
241
+ # and launcher.exe cannot be trusted to match it, so do not restart anything.
242
+ $rebuildOk = $false
243
+ }
244
+
245
+ # If the rebuild did not produce a good launcher.exe, the tasks were already
246
+ # stopped above to release the binary. Restarting any of them now would run them
247
+ # against a broken or missing exe and leave the bridge down. Skip the restart
248
+ # loop, surface a loud actionable message, and leave the tasks stopped.
249
+ if (-not $rebuildOk) {
250
+ Write-Host "acstack bridge: launcher.exe is NOT in a known-good state - skipping all task restarts. Run build-launcher.ps1 manually, then start the Playwriter-* tasks."
251
+ exit 0
252
+ }
253
+
254
+ # Restart the affected, eligible tasks. Stop is a no-op if a task is already
255
+ # stopped (e.g. it was stopped above for the launcher rebuild, or it had failed).
256
+ foreach ($name in $restartList) {
257
+ Stop-BridgeTaskTree -Name $name
258
+ Start-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
259
+ Write-Host "acstack bridge: restarted $name"
260
+ }
261
+
262
+ exit 0
263
+
264
+ } finally {
265
+ # Release the mutex on every exit path. Runs even when 'exit' fires inside the
266
+ # try above (FIX A early exit, the normal exit 0, or an uncaught throw).
267
+ if ($mutex) {
268
+ try { $mutex.ReleaseMutex() } catch { }
269
+ $mutex.Dispose()
270
+ }
271
+ }
@@ -0,0 +1,325 @@
1
+ # tunnel.ps1 – Reverse SSH tunnel with exponential backoff and fatal-error
2
+ # classification.
3
+ #
4
+ # Opens `ssh -N -R 19988:127.0.0.1:19988 <user>@<host>` so the VM can reach
5
+ # the local Playwriter relay at 127.0.0.1:19988. Intended to be invoked by a
6
+ # scheduled task registered by register-tasks.ps1. Logs to
7
+ # %LOCALAPPDATA%\playwriter-bridge\tunnel-<host>.log.
8
+ #
9
+ # ExitOnForwardFailure=yes is load-bearing: if the remote side already has
10
+ # something bound to 19988 (stale forward), ssh would otherwise report
11
+ # success without the forward and the bridge would silently not work.
12
+ #
13
+ # Retry policy:
14
+ # - Transient errors (connection refused / timed out / no route / TCP reset
15
+ # under a live session / Win32 connect "Unknown error") are retried with
16
+ # exponential backoff 5 → 10 → 20 → 40 → 80 → 120 s cap.
17
+ # - Successful bind (process ran at least 30 s) resets the backoff.
18
+ # - "remote port forwarding failed" is retried for as long as it takes, and
19
+ # never surrendered. It clears once the VM's sshd reaps the orphaned
20
+ # forward channel left by the dropped session (ClientAliveInterval ×
21
+ # ClientAliveCountMax – ~90 s on a VM configured per the runbook, up to
22
+ # ~360 s on a default/cloud-image VM), and a holder that outlasts that is
23
+ # still a port that comes back the moment someone frees it. The wait grows
24
+ # 30 → 60 → 120 s and caps there, so a long wedge costs one attempt every
25
+ # two minutes rather than a hammered port. After 8 consecutive stuck
26
+ # attempts (~11 min) the port is held by something the reaper is not going
27
+ # to release, so a stuck marker goes down for doctor.ps1 to report – but the
28
+ # retries carry on underneath it, and the marker is cleared as soon as a
29
+ # forward stays bound for 30 s, observed live rather than after ssh exits.
30
+ # - "Permission denied" or "Host key verification failed" exit immediately
31
+ # with a marker. These require user action.
32
+ #
33
+ # Marker file: tunnel-<host>.stuck next to the log. Contains the reason,
34
+ # timestamp, and the last 20 log lines. doctor.ps1 reads this.
35
+ #
36
+ # Port 19988 is the bridge's canonical CDP port and is the default here.
37
+ # Port 19989 carries the document opener, registered as a second task
38
+ # instance of this same script. 19988 is hardcoded across
39
+ # the setup. If you ever need to change it, update every occurrence in:
40
+ # assets/playwriter-bridge/{tunnel.ps1, relay.ps1, stop.ps1, doctor.ps1,
41
+ # register-tasks.ps1, vm-free-port.sh}
42
+ # agents/frontend-verifier.md, skills/browser-verify/SKILL.md,
43
+ # skills/bridge/{SKILL.md, SKILL-WINDOWS.md, SKILL-VM.md, HANDOFF.md}
44
+ # The relay binds it via `playwriter serve`'s default; the rest reference
45
+ # it literally.
46
+
47
+ param(
48
+ [Parameter(Mandatory = $true)]
49
+ [string]$TargetHost,
50
+
51
+ [Parameter(Mandatory = $false)]
52
+ [string]$User = $env:USERNAME,
53
+
54
+ # Which port to carry. 19988 is the bridge's CDP port; 19989 is the
55
+ # document opener. They are forwarded by separate task instances rather
56
+ # than as two -R flags on one connection, deliberately: with
57
+ # ExitOnForwardFailure=yes a single wedged port fails the whole ssh, so
58
+ # sharing a connection would let a stuck opener port take the bridge down
59
+ # with it. Separate connections keep the two failures independent.
60
+ [Parameter(Mandatory = $false)]
61
+ [int]$Port = 19988
62
+ )
63
+
64
+ # Console hiding is handled by launcher.exe (spawns PowerShell with
65
+ # CREATE_NO_WINDOW). Nothing to do here.
66
+
67
+ $ErrorActionPreference = "Continue"
68
+
69
+ $logDir = Join-Path $env:LOCALAPPDATA "playwriter-bridge"
70
+ New-Item -Path $logDir -ItemType Directory -Force | Out-Null
71
+ $logSuffix = if ($Port -eq 19988) { "" } else { "-$Port" }
72
+ $logFile = Join-Path $logDir "tunnel-$TargetHost$logSuffix.log"
73
+ $markerFile = Join-Path $logDir "tunnel-$TargetHost$logSuffix.stuck"
74
+ # ssh is started as a child we can watch rather than run inline, so its streams
75
+ # have to land somewhere we can read after it exits. Two files: PowerShell 5.1
76
+ # refuses to redirect both streams to one.
77
+ $errFile = Join-Path $logDir "tunnel-$TargetHost$logSuffix.err"
78
+ $outFile = Join-Path $logDir "tunnel-$TargetHost$logSuffix.out"
79
+
80
+ # The stuck marker is NOT cleared on start. This loop never exits on a wedged
81
+ # port, but the task can still be restarted underneath it, and clearing on
82
+ # start would let doctor.ps1 sample green during a still-wedged restart. The
83
+ # marker reflects "still needs attention until a real bind succeeds" – it is
84
+ # cleared only after a successful bind (see the proof-of-life path below).
85
+
86
+ function Write-Log {
87
+ param(
88
+ [string]$State,
89
+ [int]$ExitCode,
90
+ [string]$Message
91
+ )
92
+ $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
93
+ "[$ts] [$State] [$ExitCode] $Message" | Out-File -Append -FilePath $logFile -Encoding utf8
94
+ }
95
+
96
+ function Rotate-Log {
97
+ if (-not (Test-Path $logFile)) { return }
98
+ $size = (Get-Item $logFile).Length
99
+ if ($size -lt 1MB) { return }
100
+ $prior = "$logFile.1"
101
+ Remove-Item $prior -ErrorAction SilentlyContinue
102
+ Move-Item $logFile $prior -ErrorAction SilentlyContinue
103
+ }
104
+
105
+ function Write-Marker {
106
+ param(
107
+ [string]$Reason,
108
+ [string]$Remediation
109
+ )
110
+ $tail = @()
111
+ if (Test-Path $logFile) {
112
+ $tail = Get-Content $logFile -Tail 20
113
+ }
114
+ $content = @()
115
+ $content += "timestamp: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
116
+ $content += "target: $remote"
117
+ $content += "reason: $Reason"
118
+ $content += "remediation: $Remediation"
119
+ $content += ""
120
+ $content += "--- last 20 log lines ---"
121
+ $content += $tail
122
+ $content | Out-File -FilePath $markerFile -Encoding utf8
123
+ }
124
+
125
+ function Classify-Output {
126
+ param([string[]]$Lines)
127
+ $joined = ($Lines -join "`n")
128
+ if ($joined -match 'remote port forwarding failed for listen port') {
129
+ return 'stuck-port'
130
+ }
131
+ if ($joined -match 'Permission denied \(publickey') {
132
+ return 'auth-denied'
133
+ }
134
+ if ($joined -match 'Host key verification failed') {
135
+ return 'host-key'
136
+ }
137
+ # 'Connection reset' / 'client_loop: send disconnect' is OpenSSH's message
138
+ # when the TCP socket of a live session is reset under it (wifi roam, VPN
139
+ # flap, suspend/resume). 'port <n>: Unknown error' is Win32-OpenSSH's
140
+ # WSAEHOSTUNREACH/WSAENETUNREACH on connect. Both are recoverable network
141
+ # events – classify transient so they back off and reconnect rather than
142
+ # falling through to 'unknown'.
143
+ if ($joined -match 'Connection refused|Connection timed out|Connection reset|client_loop: send disconnect|No route to host|Network is unreachable|Could not resolve hostname|port \d+: Unknown error') {
144
+ return 'transient'
145
+ }
146
+ return 'unknown'
147
+ }
148
+
149
+ $sshExe = Join-Path $env:WINDIR "System32\OpenSSH\ssh.exe"
150
+ if (-not (Test-Path $sshExe)) {
151
+ $onPath = Get-Command ssh -ErrorAction SilentlyContinue
152
+ if ($onPath) { $sshExe = $onPath.Source }
153
+ else { throw "ssh.exe not found. Enable the Windows OpenSSH Client optional feature." }
154
+ }
155
+
156
+ $remote = "$User@$TargetHost"
157
+
158
+ $backoff = @(5, 10, 20, 40, 80, 120)
159
+ $backoffIndex = 0
160
+ $stuckStreak = 0
161
+ $startFailStreak = 0
162
+
163
+ # Stuck-port retry policy. There is no budget: giving up hands recovery to the
164
+ # Scheduler backstop, which on a wedged port is a restart loop nobody watches,
165
+ # and the bridge is simply down between the exit and the restart. So the loop
166
+ # outlasts the wedge instead. The first wait is sized to the reap window and
167
+ # then doubles to a 120 s cap – the same ceiling the transient path uses – so
168
+ # a port held for hours costs one attempt every two minutes.
169
+ $stuckRetrySeconds = 30
170
+ $stuckMaxRetrySeconds = 120
171
+ $stuckWait = $stuckRetrySeconds
172
+ # How many consecutive stuck attempts before the port counts as genuinely held
173
+ # rather than mid-reap. The waits before the 8th attempt are
174
+ # 30 + 60 + 5 × 120 = 690 s, so the marker lands about 11 min in. That outlasts
175
+ # both a tightened (~90 s) and a default cloud-image (~360 s) reap window, so a
176
+ # marker written past it means a non-sshd holder or keepalive disabled –
177
+ # something a human has to clear. Retune this if the backoff above changes:
178
+ # with the doubling wait, a streak of 20 would be ~35 min, far too quiet.
179
+ $stuckMaxStreak = 8
180
+
181
+ while ($true) {
182
+ Rotate-Log
183
+ Write-Log -State 'start' -ExitCode 0 -Message "Starting tunnel to $remote"
184
+
185
+ Remove-Item $errFile -ErrorAction SilentlyContinue
186
+ Remove-Item $outFile -ErrorAction SilentlyContinue
187
+ $sshArgs = @(
188
+ '-N',
189
+ '-R', "$($Port):127.0.0.1:$Port",
190
+ '-o', 'ServerAliveInterval=30',
191
+ '-o', 'ServerAliveCountMax=3',
192
+ '-o', 'ExitOnForwardFailure=yes',
193
+ '-o', 'BatchMode=yes',
194
+ '-o', 'StrictHostKeyChecking=accept-new',
195
+ $remote
196
+ )
197
+
198
+ $startedAt = Get-Date
199
+ $proc = $null
200
+ try {
201
+ $proc = Start-Process -FilePath $sshExe -ArgumentList $sshArgs -NoNewWindow -PassThru `
202
+ -RedirectStandardError $errFile -RedirectStandardOutput $outFile
203
+ # Touching .Handle caches the native process handle in this object. Without
204
+ # -Wait, Start-Process does not hold one open, so Windows releases the
205
+ # handle the moment ssh exits and $proc.ExitCode below reads $null - which
206
+ # every log line then renders as [] or, through Write-Log's [int] parameter,
207
+ # as a flat [0]. Every ssh failure reported success. WaitForExit() does not
208
+ # rescue it; by then the handle is already gone.
209
+ $null = $proc.Handle
210
+ } catch {
211
+ Write-Log -State 'error' -ExitCode 1 -Message "Could not start ${sshExe}: $($_.Exception.Message)"
212
+ }
213
+ if (-not $proc) {
214
+ # A start that never happened is a tunnel that is down, and it used to be
215
+ # a silent one: the catch above logged 'error', backed off, and looped
216
+ # forever with no marker and no state word doctor.ps1 scans for, so the
217
+ # report stayed green on a tunnel that had never run. An orphaned ssh
218
+ # still holding tunnel-<host>.err is enough to cause it - Remove-Item
219
+ # fails, the redirect cannot open, and Start-Process throws.
220
+ $startFailStreak++
221
+ $startWait = $backoff[[Math]::Min($backoffIndex, $backoff.Length - 1)]
222
+ Write-Log -State 'start-failed' -ExitCode 1 -Message "ssh did not start for $remote (attempt $startFailStreak); retrying in ${startWait}s."
223
+ if ($startFailStreak -ge $stuckMaxStreak) {
224
+ # Same threshold as a wedged port, for the same reason: past it,
225
+ # this is not going to clear without a human.
226
+ Write-Marker -Reason "ssh could not be started for $remote after $startFailStreak consecutive attempts" `
227
+ -Remediation "Look in %LOCALAPPDATA%\playwriter-bridge for an orphaned ssh.exe holding tunnel-$TargetHost$logSuffix.err or .out, and check free disk space. Kill any stray ssh.exe carrying the -R $Port forward. This task keeps retrying, so it recovers on its own once the file is free."
228
+ }
229
+ Start-Sleep -Seconds $startWait
230
+ if ($backoffIndex -lt $backoff.Length - 1) { $backoffIndex++ }
231
+ continue
232
+ }
233
+ $startFailStreak = 0
234
+
235
+ # Proof of life, taken while the tunnel is still up rather than after it
236
+ # falls over. ExitOnForwardFailure=yes makes ssh die at once when the remote
237
+ # port will not bind, so a child still running at 30 s IS a bound forward.
238
+ # Waiting for the exit to notice that was the bug: a healthy tunnel stays up
239
+ # for days, and until it dropped, a stuck marker from an earlier wedge sat
240
+ # there with doctor.ps1 reporting red on a bridge that had worked all along.
241
+ $provenAlive = $false
242
+ while (-not $proc.HasExited) {
243
+ Start-Sleep -Seconds 1
244
+ if (-not $provenAlive -and ((Get-Date) - $startedAt).TotalSeconds -ge 30) {
245
+ $provenAlive = $true
246
+ $stuckStreak = 0
247
+ $stuckWait = $stuckRetrySeconds
248
+ $backoffIndex = 0
249
+ Remove-Item $markerFile -ErrorAction SilentlyContinue
250
+ Write-Log -State 'up' -ExitCode 0 -Message "Forward for port $Port bound to $remote and up for 30s. Backoff reset, any stuck marker cleared."
251
+ }
252
+ }
253
+ $proc.WaitForExit()
254
+ $code = $proc.ExitCode
255
+ $ranSeconds = ((Get-Date) - $startedAt).TotalSeconds
256
+
257
+ $output = @()
258
+ foreach ($streamFile in @($errFile, $outFile)) {
259
+ if (Test-Path $streamFile) { $output += @(Get-Content $streamFile -ErrorAction SilentlyContinue) }
260
+ }
261
+
262
+ $output | ForEach-Object { "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [ssh-stderr] [$code] $_" } |
263
+ Out-File -Append -FilePath $logFile -Encoding utf8
264
+
265
+ $class = Classify-Output -Lines $output
266
+ Write-Log -State 'exit' -ExitCode $code -Message "Tunnel to $remote exited after $([int]$ranSeconds)s, class=$class"
267
+
268
+ switch ($class) {
269
+ 'stuck-port' {
270
+ $stuckStreak++
271
+ if ($stuckStreak -ge $stuckMaxStreak) {
272
+ # Past the reap window, so this is a holder the VM will not let
273
+ # go of on its own. Say so where doctor.ps1 can read it – and
274
+ # keep retrying, because the marker is a report, not a verdict:
275
+ # the moment someone frees the port, the next attempt binds.
276
+ Write-Log -State 'stuck' -ExitCode $code -Message "Port $Port still bound on $TargetHost after $stuckStreak consecutive attempts. Marker written; still retrying every ${stuckWait}s."
277
+ Write-Marker -Reason "remote port forwarding failed – port $Port still bound on VM after $stuckStreak consecutive attempts (the VM's sshd has not reaped the orphaned forward channel)" `
278
+ -Remediation "Run ~/.claude/acstack/bin/vm-free-port.sh --port $Port on $TargetHost. This task is still retrying, so it will bind on its own once the port is free – no restart needed. If this recurs, tighten the VM's sshd ClientAliveInterval per the bridge SKILL-VM runbook so orphaned forwards reap faster."
279
+ }
280
+ # The port is held by the orphaned forward channel of the dropped
281
+ # session. Wait for the VM's sshd ClientAlive reaper to release it;
282
+ # the next attempt then binds. This is the bridge's self-heal path,
283
+ # so the wait is sized to outlast the reap window, not a token 10 s,
284
+ # and doubles to the cap so a long wedge is not hammered.
285
+ Write-Log -State 'stuck-retry' -ExitCode $code -Message "Port $Port still held on $TargetHost (attempt $stuckStreak); waiting ${stuckWait}s for the VM to reap the orphaned forward."
286
+ Start-Sleep -Seconds $stuckWait
287
+ $stuckWait = [Math]::Min($stuckWait * 2, $stuckMaxRetrySeconds)
288
+ continue
289
+ }
290
+ 'auth-denied' {
291
+ Write-Log -State 'fatal' -ExitCode $code -Message "Permission denied. Writing marker and exiting."
292
+ Write-Marker -Reason 'Permission denied (publickey)' `
293
+ -Remediation "Run 'ssh-add' on Windows and confirm the matching public key is in ~/.ssh/authorized_keys on $TargetHost."
294
+ exit 11
295
+ }
296
+ 'host-key' {
297
+ Write-Log -State 'fatal' -ExitCode $code -Message "Host key verification failed. Writing marker and exiting."
298
+ Write-Marker -Reason 'Host key verification failed' `
299
+ -Remediation "Resolve the host key mismatch for $TargetHost before retrying."
300
+ exit 12
301
+ }
302
+ default {
303
+ # Transient or unknown – apply backoff. Reset if the previous
304
+ # run lasted at least 30 s (implies the forward was bound and
305
+ # the session stayed up).
306
+ # A short transient blip must NOT zero the stuck-streak – only a
307
+ # successful bind does, so the give-up budget can't be starved by
308
+ # a single brief failure between stuck-port retries.
309
+ # Backstop for a run the poll above never got to observe – a
310
+ # suspend/resume can swallow its one-second ticks. On any normal
311
+ # long run $provenAlive already did all of this; repeating it is
312
+ # idempotent, and a run this long bound its forward either way.
313
+ if ($ranSeconds -ge 30) {
314
+ $stuckStreak = 0
315
+ $stuckWait = $stuckRetrySeconds
316
+ $backoffIndex = 0
317
+ Remove-Item $markerFile -ErrorAction SilentlyContinue
318
+ }
319
+ $wait = $backoff[[Math]::Min($backoffIndex, $backoff.Length - 1)]
320
+ Write-Log -State 'retry' -ExitCode $code -Message "Sleeping ${wait}s (class=$class, lasted $([int]$ranSeconds)s)"
321
+ Start-Sleep -Seconds $wait
322
+ if ($backoffIndex -lt $backoff.Length - 1) { $backoffIndex++ }
323
+ }
324
+ }
325
+ }