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,328 @@
1
+ # register-tasks.ps1 – Register Playwriter bridge scheduled tasks.
2
+ #
3
+ # Creates three kinds of scheduled tasks, all at user logon, LogonType=Interactive:
4
+ #
5
+ # Playwriter-Relay – runs relay.ps1 (always one)
6
+ # Playwriter-Opener – runs opener.ps1 (always one)
7
+ # Playwriter-Tunnel-VM1 – runs tunnel.ps1 -TargetHost <vm1>
8
+ # Playwriter-Tunnel-VM2 – runs tunnel.ps1 -TargetHost <vm2>
9
+ # ... (one per VM in -VMs, auto-numbered)
10
+ # Playwriter-OpenerTunnel-VM1 – runs tunnel.ps1 -TargetHost <vm1> -Port 19989
11
+ # ... (one per VM, carrying the opener's port)
12
+ #
13
+ # The opener gets its own tunnel task rather than a second -R on the bridge's
14
+ # connection. ssh runs with ExitOnForwardFailure=yes, so one wedged port fails
15
+ # the whole connection – sharing one would mean a stuck opener port takes the
16
+ # bridge down too. See docs/adr/0006-document-opener-service.md.
17
+ #
18
+ # The tasks run as the current user, so the ssh-agent named pipe (SID-ACL'd
19
+ # to the user) stays reachable and no password is ever stored. Each task
20
+ # action invokes launcher.exe, which spawns PowerShell with
21
+ # CREATE_NO_WINDOW – no console is ever allocated, so there is no flash.
22
+ #
23
+ # Run build-launcher.ps1 first to produce launcher.exe; this script refuses
24
+ # to continue without it.
25
+ #
26
+ # Usage:
27
+ #
28
+ # .\register-tasks.ps1 -VMs @("vm1.example.com", "vm2.internal")
29
+ # .\register-tasks.ps1 -VMs @("vm1.example.com") -TunnelUser dev
30
+ #
31
+ # A fresh install does not need admin. Re-registering tasks that were
32
+ # previously created from an elevated shell will fail with "Access is
33
+ # denied" – re-run this script from an elevated PowerShell in that case.
34
+ #
35
+ # Scripts are expected to live in %USERPROFILE%\playwriter-bridge\ by default.
36
+
37
+ param(
38
+ [Parameter(Mandatory = $true)]
39
+ [string[]]$VMs,
40
+
41
+ [Parameter(Mandatory = $false)]
42
+ [string]$TunnelUser = $env:USERNAME,
43
+
44
+ [Parameter(Mandatory = $false)]
45
+ [string]$ScriptsDir = (Join-Path $env:USERPROFILE "playwriter-bridge")
46
+ )
47
+
48
+ $ErrorActionPreference = "Stop"
49
+
50
+ # Soft elevation check. Fresh installs register fine without admin. Replacing
51
+ # tasks that were previously registered from an elevated shell does require
52
+ # admin – warn now so the user knows what to do if Register-ScheduledTask
53
+ # later throws "Access is denied".
54
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
55
+ $principalCheck = New-Object Security.Principal.WindowsPrincipal($identity)
56
+ if (-not $principalCheck.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
57
+ Write-Warning "Not running elevated. Fresh installs work fine. If the tasks already exist and were registered elevated, Register-ScheduledTask will fail with 'Access is denied' – re-run this from an elevated PowerShell in that case."
58
+ }
59
+
60
+ # Validate that the script files are actually where we expect them.
61
+ foreach ($file in @("relay.ps1", "tunnel.ps1", "opener.ps1", "opener.js")) {
62
+ $full = Join-Path $ScriptsDir $file
63
+ if (-not (Test-Path $full)) {
64
+ Write-Error "Missing required script: $full. Copy relay.ps1, tunnel.ps1, opener.ps1 and opener.js into $ScriptsDir before running this."
65
+ exit 1
66
+ }
67
+ }
68
+
69
+ $launcherExe = Join-Path $ScriptsDir "launcher.exe"
70
+ if (-not (Test-Path $launcherExe)) {
71
+ Write-Error "Missing launcher: $launcherExe. Run build-launcher.ps1 from $ScriptsDir first to compile it from launcher-src.cs."
72
+ exit 1
73
+ }
74
+
75
+ $psExe = Join-Path $env:WINDIR "System32\WindowsPowerShell\v1.0\powershell.exe"
76
+
77
+ # The scripts themselves own resilience (tunnel.ps1 has exponential backoff
78
+ # and fatal-error classification; relay.ps1 loops internally). The scheduler
79
+ # restart is a soft backstop for rare PowerShell-host crashes, not a retry
80
+ # engine. Keep it small so it does not fight manual stops.
81
+ $settings = New-ScheduledTaskSettingsSet `
82
+ -AllowStartIfOnBatteries `
83
+ -DontStopIfGoingOnBatteries `
84
+ -DontStopOnIdleEnd `
85
+ -ExecutionTimeLimit ([TimeSpan]::Zero) `
86
+ -RestartCount 2 `
87
+ -RestartInterval (New-TimeSpan -Minutes 5)
88
+
89
+ $userId = "$env:USERDOMAIN\$env:USERNAME"
90
+ $principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Limited
91
+ $trigger = New-ScheduledTaskTrigger -AtLogOn -User $userId
92
+
93
+ # Unregister-ScheduledTask removes the task definition but does not take down a
94
+ # running instance, and the scheduler force-terminates launcher.exe without
95
+ # letting its ProcessExit handler run - so the launcher's WMI descendant kill
96
+ # never fires and the child PowerShell, plus the ssh.exe or node.exe it was
97
+ # holding, survives as an orphan still bound to 19988/19989. The task registered
98
+ # next then never binds. Observed on Windows 11 26200 re-running this script
99
+ # over a live bridge: two orphaned tunnel trees competing on 19988, each with
100
+ # its own retry counter, writing interleaved lines into one log.
101
+ #
102
+ # Scoped by the task's own registered argument string so a tunnel task never
103
+ # reaps its sibling on the other port or the other VM. launcher.exe drops the
104
+ # quotes around -File when it builds the child command line, hence the de-quoted
105
+ # comparison on both sides.
106
+ function Stop-BridgeTaskTree {
107
+ param([string]$Name)
108
+
109
+ $task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
110
+ $want = if ($task) { ($task.Actions.Arguments -replace '"', '') } else { $null }
111
+
112
+ Stop-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
113
+
114
+ for ($i = 0; $i -lt 20; $i++) {
115
+ $state = (Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue).State
116
+ if ($state -ne 'Running') { break }
117
+ Start-Sleep -Milliseconds 250
118
+ }
119
+
120
+ if (-not $want) { return }
121
+
122
+ $survivors = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
123
+ $_.ProcessId -ne $PID -and $_.CommandLine -and ($_.CommandLine -replace '"', '') -eq $want
124
+ })
125
+ foreach ($p in $survivors) {
126
+ # Children first. Killing the supervisor alone re-orphans the ssh.exe or
127
+ # node.exe it was holding, which is the whole bug this guards against.
128
+ Get-CimInstance Win32_Process -Filter "ParentProcessId=$($p.ProcessId)" -ErrorAction SilentlyContinue |
129
+ ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
130
+ Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
131
+ Write-Host " reaped surviving $Name process (PID $($p.ProcessId))"
132
+ }
133
+ }
134
+
135
+ function Register-BridgeTask {
136
+ param(
137
+ [string]$Name,
138
+ [string]$ScriptFile,
139
+ [string[]]$ExtraArgs = @()
140
+ )
141
+ $scriptPath = Join-Path $ScriptsDir $ScriptFile
142
+ # launcher.exe spawns args[0] (PowerShell) with CREATE_NO_WINDOW. Its
143
+ # remaining args get joined and passed as PowerShell's Arguments. Any
144
+ # path with spaces must be pre-quoted here.
145
+ $launcherArgs = @(
146
+ "`"$psExe`"",
147
+ "-NoProfile",
148
+ "-ExecutionPolicy", "Bypass",
149
+ "-File", "`"$scriptPath`""
150
+ ) + $ExtraArgs
151
+ $argString = $launcherArgs -join ' '
152
+
153
+ $action = New-ScheduledTaskAction -Execute $launcherExe -Argument $argString
154
+
155
+ # Explicit Unregister: do NOT silently swallow "Access is denied".
156
+ # Previous versions hid that error, then Register-ScheduledTask threw
157
+ # a non-terminating CIM "Cannot create a file when that file already
158
+ # exists" and the old task (possibly with stale settings) stayed put
159
+ # while the script falsely reported success.
160
+ $existing = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
161
+ if ($existing) {
162
+ # Take the running instance down first, children included. Unregistering
163
+ # a running task leaves its process tree alive and holding the port.
164
+ Stop-BridgeTaskTree -Name $Name
165
+ try {
166
+ Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction Stop
167
+ } catch {
168
+ Write-Error ("Cannot replace task '{0}': {1}`n" -f $Name, $_.Exception.Message) -ErrorAction Continue
169
+ Write-Error "This task was probably registered from an elevated PowerShell. Re-run this script from an elevated PowerShell (Run as Administrator)." -ErrorAction Continue
170
+ throw "Unregister-ScheduledTask failed for $Name"
171
+ }
172
+ }
173
+
174
+ try {
175
+ Register-ScheduledTask `
176
+ -TaskName $Name `
177
+ -Action $action `
178
+ -Trigger $trigger `
179
+ -Principal $principal `
180
+ -Settings $settings `
181
+ -ErrorAction Stop | Out-Null
182
+ } catch {
183
+ Write-Error ("Register-ScheduledTask failed for '{0}': {1}" -f $Name, $_.Exception.Message) -ErrorAction Continue
184
+ throw
185
+ }
186
+
187
+ # Verify the new settings actually landed. Guards against a silent
188
+ # partial failure where the task exists but with old settings. We check
189
+ # every field whose property name is identical on the input
190
+ # ScheduledTaskSettings object and on the fetched task's Settings – the
191
+ # battery/idle switch-params invert to 'Disallow*'/'StopIf*' in the
192
+ # stored representation and aren't safely comparable by name, so they
193
+ # are left out. RestartCount, RestartInterval and ExecutionTimeLimit
194
+ # map 1:1 on both sides and are the fields most likely to silently
195
+ # reject.
196
+ $check = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
197
+ if (-not $check) {
198
+ throw "Post-register check: task '$Name' is missing."
199
+ }
200
+ $fieldsToCheck = @('RestartCount', 'RestartInterval', 'ExecutionTimeLimit')
201
+ $mismatches = @()
202
+ foreach ($key in $fieldsToCheck) {
203
+ $want = $settings.$key
204
+ $got = $check.Settings.$key
205
+ if ($got -ne $want) {
206
+ $mismatches += ("{0}: got '{1}', expected '{2}'" -f $key, $got, $want)
207
+ }
208
+ }
209
+ if ($mismatches.Count -gt 0) {
210
+ throw ("Post-register check: task '{0}' has stale settings ({1}). The register silently applied stale settings – re-run from an elevated PowerShell." -f $Name, ($mismatches -join '; '))
211
+ }
212
+
213
+ Write-Host ("Registered: {0} ({1} settings fields verified: {2})" -f $Name, $fieldsToCheck.Count, ($fieldsToCheck -join ', '))
214
+ }
215
+
216
+ Register-BridgeTask -Name "Playwriter-Relay" -ScriptFile "relay.ps1"
217
+ Register-BridgeTask -Name "Playwriter-Opener" -ScriptFile "opener.ps1"
218
+
219
+ for ($i = 0; $i -lt $VMs.Count; $i++) {
220
+ $vm = $VMs[$i]
221
+ Register-BridgeTask `
222
+ -Name "Playwriter-Tunnel-VM$($i + 1)" `
223
+ -ScriptFile "tunnel.ps1" `
224
+ -ExtraArgs @("-TargetHost", $vm, "-User", $TunnelUser)
225
+ Register-BridgeTask `
226
+ -Name "Playwriter-OpenerTunnel-VM$($i + 1)" `
227
+ -ScriptFile "tunnel.ps1" `
228
+ -ExtraArgs @("-TargetHost", $vm, "-User", $TunnelUser, "-Port", "19989")
229
+ }
230
+
231
+ # The opener refuses every request that does not carry this, so each VM needs
232
+ # a copy. Generated here if the opener has not already made one; pushed over
233
+ # the same ssh the tunnel uses, so it needs no new access. A VM that cannot be
234
+ # reached is a warning, not a failure: the tunnel tasks are still worth
235
+ # registering, and the token can be copied by hand afterwards.
236
+ $tokenFile = Join-Path $env:LOCALAPPDATA "playwriter-bridge\opener.token"
237
+ if (-not (Test-Path $tokenFile)) {
238
+ New-Item -Path (Split-Path $tokenFile) -ItemType Directory -Force | Out-Null
239
+ $bytes = New-Object byte[] 32
240
+ [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
241
+ Set-Content -Path $tokenFile -Value ([System.BitConverter]::ToString($bytes).Replace('-', '').ToLower()) -NoNewline -Encoding ascii
242
+ Write-Host "Generated opener token: $tokenFile"
243
+ }
244
+ $token = (Get-Content $tokenFile -Raw).Trim()
245
+
246
+ Write-Host ""
247
+ Write-Host "Pushing opener token to VMs..."
248
+ # Resolve ssh the same way tunnel.ps1 does. A bare 'ssh' can resolve to the Git
249
+ # for Windows build, which cannot reach the Windows ssh-agent named pipe and so
250
+ # fails with "Permission denied (publickey)" - the push would then be skipped
251
+ # with only a warning and the VM would 401 on every opener request.
252
+ $sshExe = Join-Path $env:WINDIR "System32\OpenSSH\ssh.exe"
253
+ if (-not (Test-Path $sshExe)) {
254
+ $onPath = Get-Command ssh -ErrorAction SilentlyContinue
255
+ if ($onPath) {
256
+ $sshExe = $onPath.Source
257
+ } else {
258
+ # A warning, never a throw. The tasks are already registered by this
259
+ # point and the start loop is still ahead, so throwing here left every
260
+ # task registered and none of them running.
261
+ Write-Warning "ssh.exe not found, so the opener token was not pushed. Enable the Windows OpenSSH Client optional feature, then copy $tokenFile to ~/.config/acstack/opener.token on each VM by hand."
262
+ $sshExe = $null
263
+ }
264
+ }
265
+ foreach ($vm in $VMs) {
266
+ if (-not $sshExe) { break }
267
+ $remote = "$TunnelUser@$vm"
268
+ # Single-quoted on the remote side so the shell there cannot interpret it,
269
+ # and 0600 because it is a bearer credential for opening pages on this
270
+ # screen.
271
+ $cmd = "mkdir -p ~/.config/acstack && umask 077 && printf '%s' '$token' > ~/.config/acstack/opener.token"
272
+ # $ErrorActionPreference is 'Stop' for this script, and merging a native
273
+ # command's stderr into the pipeline under it turns any stderr line into a
274
+ # terminating NativeCommandError. ssh writes one on a first connection:
275
+ # "Warning: Permanently added ... to the list of known hosts". That aborted
276
+ # the run before the start loop, on nothing but a routine notice. Discard
277
+ # stderr and read the real result from the exit code.
278
+ $prevEap = $ErrorActionPreference
279
+ $ErrorActionPreference = 'Continue'
280
+ & $sshExe -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 $remote $cmd 2>$null | Out-Null
281
+ $sshCode = $LASTEXITCODE
282
+ $ErrorActionPreference = $prevEap
283
+ if ($sshCode -eq 0) {
284
+ Write-Host " $vm : token installed at ~/.config/acstack/opener.token"
285
+ } else {
286
+ Write-Warning " $vm : could not push the token (ssh exit $sshCode). Copy $tokenFile to ~/.config/acstack/opener.token on that VM by hand."
287
+ }
288
+ }
289
+
290
+ # Tasks whose VM is not in -VMs. A re-run with a shorter list leaves the earlier
291
+ # tunnels registered and untouched: this script removes nothing. The case that
292
+ # matters is a VM behind a VPN that was down at registration time. Its
293
+ # Playwriter-Tunnel-VM2 must survive a re-run that could not reach it, because
294
+ # dropping the task would silently drop that VM from the bridge - and the user
295
+ # would find out the next time they needed a browser on it.
296
+ #
297
+ # Reported here rather than after the start loop, because a warning printed
298
+ # under "Starting tasks..." reads as a task that failed to start.
299
+ $leftover = @()
300
+ foreach ($t in @(Get-ScheduledTask -TaskName 'Playwriter-Tunnel-*','Playwriter-OpenerTunnel-*' -ErrorAction SilentlyContinue)) {
301
+ # The VM is in the action arguments, not the task name: an index-based guess
302
+ # would mislabel every task whenever -VMs is reordered.
303
+ $argText = ($t.Actions | ForEach-Object { $_.Arguments }) -join ' '
304
+ if ($argText -match '-TargetHost\s+(\S+)') {
305
+ $taskTarget = $Matches[1].Trim('"')
306
+ if ($VMs -notcontains $taskTarget) {
307
+ $leftover += [pscustomobject]@{ Name = $t.TaskName; Target = $taskTarget }
308
+ }
309
+ }
310
+ }
311
+ if ($leftover.Count -gt 0) {
312
+ Write-Host ""
313
+ foreach ($l in $leftover) {
314
+ Write-Warning ("Left alone: task '{0}' forwards to {1}, which is not in -VMs. It stays registered and keeps running. This script removes nothing. Re-run with {1} in -VMs to update it, or delete the task yourself in Task Scheduler." -f $l.Name, $l.Target)
315
+ }
316
+ }
317
+
318
+ Write-Host ""
319
+ Write-Host "Starting tasks..."
320
+ Start-ScheduledTask -TaskName "Playwriter-Relay"
321
+ Start-ScheduledTask -TaskName "Playwriter-Opener"
322
+ Start-Sleep -Seconds 2
323
+ for ($i = 0; $i -lt $VMs.Count; $i++) {
324
+ Start-ScheduledTask -TaskName "Playwriter-Tunnel-VM$($i + 1)"
325
+ Start-ScheduledTask -TaskName "Playwriter-OpenerTunnel-VM$($i + 1)"
326
+ }
327
+
328
+ Write-Host "Done. Logs: $env:LOCALAPPDATA\playwriter-bridge\"
@@ -0,0 +1,50 @@
1
+ # relay.ps1 – Playwriter relay with auto-reconnect.
2
+ #
3
+ # Runs `playwriter serve --host 127.0.0.1 --replace` in a loop. Intended to
4
+ # be invoked through launcher.exe by a scheduled task registered by
5
+ # register-tasks.ps1. Logs to %LOCALAPPDATA%\playwriter-bridge\relay.log.
6
+ #
7
+ # Console hiding is handled by launcher.exe (spawns PowerShell with
8
+ # CREATE_NO_WINDOW). Nothing to do here.
9
+
10
+ $ErrorActionPreference = "Continue"
11
+
12
+ $logDir = Join-Path $env:LOCALAPPDATA "playwriter-bridge"
13
+ New-Item -Path $logDir -ItemType Directory -Force | Out-Null
14
+ $logFile = Join-Path $logDir "relay.log"
15
+
16
+ function Write-Log {
17
+ param([string]$Message)
18
+ "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Message" | Out-File -Append -FilePath $logFile
19
+ }
20
+
21
+ function Resolve-PlaywriterCommand {
22
+ # Prefer the npm shim on PATH – it routes through Node correctly without
23
+ # us needing to know where either lives.
24
+ $cmd = Get-Command playwriter -ErrorAction SilentlyContinue
25
+ if ($cmd) { return @{ Exe = $cmd.Source; Args = @() } }
26
+
27
+ # Fallback: invoke Node directly against the installed CLI JS.
28
+ $node = Get-Command node -ErrorAction SilentlyContinue
29
+ if (-not $node) {
30
+ throw "Neither 'playwriter' nor 'node' is on PATH. Install Node LTS and 'npm install -g playwriter' first."
31
+ }
32
+ $cliJs = Join-Path $env:APPDATA "npm\node_modules\playwriter\dist\cli.js"
33
+ if (-not (Test-Path $cliJs)) {
34
+ throw "Found Node but not Playwriter CLI at $cliJs. Run 'npm install -g playwriter'."
35
+ }
36
+ return @{ Exe = $node.Source; Args = @($cliJs) }
37
+ }
38
+
39
+ while ($true) {
40
+ try {
41
+ $resolved = Resolve-PlaywriterCommand
42
+ Write-Log "Starting Playwriter relay ($($resolved.Exe))"
43
+ $allArgs = $resolved.Args + @("serve", "--host", "127.0.0.1", "--replace")
44
+ & $resolved.Exe @allArgs 2>&1 | Out-File -Append -FilePath $logFile
45
+ Write-Log "Relay exited (code $LASTEXITCODE), sleeping 5s"
46
+ } catch {
47
+ Write-Log "Error: $($_.Exception.Message). Sleeping 5s"
48
+ }
49
+ Start-Sleep -Seconds 5
50
+ }
@@ -0,0 +1,125 @@
1
+ # stop.ps1 – Canonical "stop the bridge" entry point.
2
+ #
3
+ # Stops every Playwriter-* scheduled task - which covers the document opener
4
+ # and its tunnel too, both registered under the same prefix - kills any
5
+ # lingering bridge
6
+ # processes (relay.ps1, tunnel.ps1), reaps orphan ssh.exe children that
7
+ # were carrying the reverse forward, and confirms 127.0.0.1:19988 is no
8
+ # longer bound. Prints a one-line verdict.
9
+ #
10
+ # Safe to run at any time. No-ops if the bridge is already down.
11
+ #
12
+ # Implementation notes:
13
+ # - The sweep matches on command line. Since this script itself may run
14
+ # from a shell whose command line contains 'playwriter-bridge', we
15
+ # exclude $PID (and its parent) to avoid self-kill.
16
+ # - When the launcher is force-killed, its ProcessExit handler does NOT
17
+ # run, so the ssh.exe child it spawned is orphaned. A dedicated pass
18
+ # looks for ssh.exe processes carrying the 19988 reverse forward and
19
+ # kills them explicitly.
20
+
21
+ $ErrorActionPreference = 'Continue'
22
+
23
+ $selfPid = $PID
24
+ try { $selfParent = (Get-CimInstance Win32_Process -Filter "ProcessId=$selfPid").ParentProcessId } catch { $selfParent = 0 }
25
+
26
+ function Should-Skip {
27
+ param([int]$TargetPid)
28
+ return ($TargetPid -eq $selfPid) -or ($TargetPid -eq $selfParent)
29
+ }
30
+
31
+ Write-Host "Stopping Playwriter-* scheduled tasks..."
32
+ $tasks = Get-ScheduledTask -TaskName 'Playwriter-*' -ErrorAction SilentlyContinue
33
+ foreach ($t in $tasks) {
34
+ try {
35
+ Stop-ScheduledTask -TaskName $t.TaskName -ErrorAction SilentlyContinue
36
+ Write-Host " stopped: $($t.TaskName)"
37
+ } catch {
38
+ Write-Host " (could not stop $($t.TaskName): $_)"
39
+ }
40
+ }
41
+
42
+ # Pass 1: kill launcher.exe / PowerShell children that were driving the
43
+ # bridge. Excludes this script and its parent so we do not kill ourselves.
44
+ $bridgePattern = 'playwriter-bridge'
45
+ $pass1 = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
46
+ Where-Object {
47
+ $_.CommandLine -and
48
+ $_.CommandLine -match $bridgePattern -and
49
+ -not (Should-Skip $_.ProcessId)
50
+ }
51
+
52
+ foreach ($p in $pass1) {
53
+ try {
54
+ Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
55
+ Write-Host " killed: PID $($p.ProcessId) ($($p.Name))"
56
+ } catch { }
57
+ }
58
+
59
+ Start-Sleep -Milliseconds 500
60
+
61
+ # Pass 2: reap orphan ssh.exe reverse-forward children. Required because
62
+ # Stop-Process -Force on the launcher does not run the launcher's
63
+ # ProcessExit handler, so the ssh.exe it parented survives. Match on the
64
+ # reverse-forward argument pattern (narrow, scoped to the two ports this setup
65
+ # owns - 19988 for the bridge, 19989 for the document opener) so this never
66
+ # touches an unrelated ssh session.
67
+ $sshRemnants = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
68
+ Where-Object {
69
+ $_.Name -eq 'ssh.exe' -and
70
+ $_.CommandLine -and
71
+ ($_.CommandLine -match '1998[89]:127\.0\.0\.1:1998[89]' -or $_.CommandLine -match '-R\s+"?1998[89]:') -and
72
+ -not (Should-Skip $_.ProcessId)
73
+ }
74
+
75
+ foreach ($p in $sshRemnants) {
76
+ try {
77
+ Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
78
+ Write-Host " killed: PID $($p.ProcessId) (ssh.exe, reverse forward)"
79
+ } catch { }
80
+ }
81
+
82
+ Start-Sleep -Milliseconds 500
83
+
84
+ # Pass 3: reap the orphan node.exe that relay.ps1 spawned (playwriter serve)
85
+ # and still binds 19988, and the one opener.ps1 spawned and still binds 19989.
86
+ # Identify each by whatever owns the port, then kill only if its command line
87
+ # names the thing we expect - so we never touch an unrelated listener that
88
+ # happens to be on either port.
89
+ $owned = @{ 19988 = 'playwriter'; 19989 = 'opener\.js' }
90
+ foreach ($port in $owned.Keys) {
91
+ $listener = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue
92
+ foreach ($conn in $listener) {
93
+ $ownerPid = [int]$conn.OwningProcess
94
+ if ($ownerPid -le 0) { continue }
95
+ if (Should-Skip $ownerPid) { continue }
96
+ $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$ownerPid" -ErrorAction SilentlyContinue
97
+ if (-not $proc) { continue }
98
+ if ($proc.CommandLine -and $proc.CommandLine -match $owned[$port]) {
99
+ try {
100
+ Stop-Process -Id $ownerPid -Force -ErrorAction SilentlyContinue
101
+ Write-Host " killed: PID $ownerPid ($($proc.Name), orphan listener on $port)"
102
+ } catch { }
103
+ } else {
104
+ Write-Host " NOT killing PID $ownerPid ($($proc.Name)): command line does not match '$($owned[$port])'" -ForegroundColor Yellow
105
+ }
106
+ }
107
+ }
108
+
109
+ Start-Sleep -Milliseconds 500
110
+
111
+ $stillUp = @()
112
+ foreach ($port in $owned.Keys) {
113
+ if (Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue) {
114
+ $stillUp += $port
115
+ }
116
+ }
117
+ if ($stillUp) {
118
+ Write-Host ""
119
+ Write-Host "VERDICT: PARTIALLY down – something still listens on $($stillUp -join ', ')" -ForegroundColor Yellow
120
+ exit 1
121
+ }
122
+
123
+ Write-Host ""
124
+ Write-Host "VERDICT: bridge and opener are down" -ForegroundColor Green
125
+ exit 0