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.
- package/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/README.md +389 -2
- package/THIRD_PARTY_NOTICES +14 -0
- package/TROUBLESHOOTING.md +225 -0
- package/agents/codex/frontend-verifier.toml +352 -0
- package/agents/codex/task-checker.toml +345 -0
- package/agents/codex/task-executor.toml +157 -0
- package/agents/codex/test-writer.toml +149 -0
- package/agents/frontend-verifier.md +351 -0
- package/agents/task-checker.md +344 -0
- package/agents/task-executor.md +156 -0
- package/agents/test-writer.md +148 -0
- package/assets/playwriter-bridge/build-launcher.ps1 +32 -0
- package/assets/playwriter-bridge/doctor.ps1 +450 -0
- package/assets/playwriter-bridge/launcher-src.cs +131 -0
- package/assets/playwriter-bridge/opener.js +330 -0
- package/assets/playwriter-bridge/opener.ps1 +63 -0
- package/assets/playwriter-bridge/register-tasks.ps1 +328 -0
- package/assets/playwriter-bridge/relay.ps1 +50 -0
- package/assets/playwriter-bridge/stop.ps1 +125 -0
- package/assets/playwriter-bridge/sync.ps1 +271 -0
- package/assets/playwriter-bridge/tunnel.ps1 +325 -0
- package/assets/playwriter-bridge/vm-free-port.sh +166 -0
- package/assets/terminal-workspace/client-keybindings.md +87 -0
- package/assets/terminal-workspace/config.example.yaml +329 -0
- package/assets/terminal-workspace/setup.sh +4317 -0
- package/cli/README.md +47 -0
- package/cli/bin/acstack.js +3 -0
- package/cli/dist/index.js +25984 -0
- package/cli/dist/index.js.map +7 -0
- package/cli/package.json +59 -0
- package/docs/adr/0001-ste-writing-standard.md +16 -0
- package/docs/adr/0002-version-gate-not-auto-bump.md +35 -0
- package/docs/adr/0003-handoff-ack-command.md +19 -0
- package/docs/adr/0004-dev-setup-per-host.md +13 -0
- package/docs/adr/0005-two-orchestrators-per-host.md +25 -0
- package/docs/adr/0006-document-opener-service.md +89 -0
- package/docs/adr/0007-lead-check-reads-the-transcript.md +87 -0
- package/docs/adr/0008-cli-audits-skill-interviews.md +37 -0
- package/docs/adr/0009-verify-the-write-not-the-writer.md +51 -0
- package/docs/adr/0010-promote-dev-to-main-by-fast-forward.md +115 -0
- package/docs/adr/0011-node-header-is-the-edge-encoding.md +20 -0
- package/docs/adr/0012-node-kind-is-a-closed-set-of-five.md +31 -0
- package/docs/adr/0013-the-diagram-renderer-is-pure.md +23 -0
- package/docs/adr/0014-reading-a-node-hides-its-generated-diagram.md +29 -0
- package/docs/adr/0015-a-node-id-links-to-its-issue-through-an-anchor.md +37 -0
- package/docs/adr/0016-the-map-root-carries-no-status-of-its-own.md +21 -0
- package/docs/adr/0017-call-impeccable-copy-only-its-questions.md +13 -0
- package/docs/adr/index.md +17 -0
- package/docs/config-reference.md +572 -0
- package/docs/dev-setups.md +164 -0
- package/docs/migrate-0.8.md +28 -0
- package/docs/playwriter-bridge-windows.md +15 -0
- package/docs/terminal-workspace-keys.md +137 -0
- package/docs/terminal-workspace.md +1774 -0
- package/docs/workflows.md +309 -0
- package/hooks/ac-writing-style-reminder.sh +86 -0
- package/hooks/context-pressure.sh +583 -0
- package/hooks/hooks.json +50 -0
- package/hooks/session-start-handoff.sh +165 -0
- package/hooks/session-start.sh +214 -0
- package/orchestrator/AGENTS.md +532 -0
- package/output-styles/ac-writing-style.md +286 -0
- package/package.json +50 -4
- package/pi/agents/frontend-verifier.md +357 -0
- package/pi/agents/task-checker.md +348 -0
- package/pi/agents/task-executor.md +160 -0
- package/pi/agents/test-writer.md +154 -0
- package/pi/extension.ts +723 -0
- package/profiles/fullstack-python.yaml +29 -0
- package/profiles/node-typescript.yaml +23 -0
- package/profiles/python.yaml +24 -0
- package/skills/archive/SKILL.md +160 -0
- package/skills/bootstrap/SKILL.md +109 -0
- package/skills/bridge/HANDOFF.md +93 -0
- package/skills/bridge/SKILL-VM.md +292 -0
- package/skills/bridge/SKILL-WINDOWS.md +526 -0
- package/skills/bridge/SKILL.md +121 -0
- package/skills/browser-verify/SKILL.md +229 -0
- package/skills/code-review/SKILL.md +44 -0
- package/skills/commit/SKILL.md +106 -0
- package/skills/compact-and-continue/SKILL.md +241 -0
- package/skills/explore/SKILL.md +149 -0
- package/skills/grilling/SKILL.md +134 -0
- package/skills/handoff/SKILL.md +250 -0
- package/skills/handoff/launch-herdr.md +197 -0
- package/skills/handoff/launch-without-herdr.md +30 -0
- package/skills/handoff/lead-check.md +59 -0
- package/skills/handoff/outcomes.md +162 -0
- package/skills/handoff/running-agent.md +50 -0
- package/skills/handoff/watch.md +115 -0
- package/skills/host/SKILL.md +247 -0
- package/skills/host/detect-host.sh +297 -0
- package/skills/host/herdr.md +79 -0
- package/skills/host/install-herdr-block.sh +467 -0
- package/skills/host/install-orca.sh +512 -0
- package/skills/host/notes.md +97 -0
- package/skills/host/orca.md +160 -0
- package/skills/host/questions.md +163 -0
- package/skills/host/report.md +42 -0
- package/skills/implement/SKILL.md +230 -0
- package/skills/implement/decision-log.md +62 -0
- package/skills/implement/design-pass.md +32 -0
- package/skills/implement/relaxed-tdd.md +28 -0
- package/skills/map/SKILL.md +250 -0
- package/skills/map/frontend.md +227 -0
- package/skills/map/trackers/files.md +74 -0
- package/skills/map/trackers/github.md +180 -0
- package/skills/map/visuals.md +387 -0
- package/skills/new-worktree/SKILL.md +155 -0
- package/skills/new-worktree/detect-orchestrator.sh +328 -0
- package/skills/new-worktree/detect-orchestrator.test.sh +894 -0
- package/skills/new-worktree/herdr.md +84 -0
- package/skills/new-worktree/orca.md +101 -0
- package/skills/pre-commit-review/SKILL.md +242 -0
- package/skills/pre-commit-review/auto-fix.md +19 -0
- package/skills/pre-commit-review/interactive.md +35 -0
- package/skills/quick-fix/SKILL.md +125 -0
- package/skills/record-context/SKILL.md +106 -0
- package/skills/setup/SKILL.md +250 -0
- package/skills/setup/browser.md +123 -0
- package/skills/setup/design-review.md +97 -0
- package/skills/setup/first-run.md +114 -0
- package/skills/setup/report.md +68 -0
- package/skills/setup/row-fixes.md +196 -0
- package/skills/setup/terminal-workspace.md +153 -0
- package/skills/sync/SKILL.md +171 -0
- package/skills/teardown-worktree/SKILL.md +245 -0
- package/skills/teardown-worktree/detector.md +39 -0
- package/skills/teardown-worktree/herdr.md +113 -0
- package/skills/teardown-worktree/orca.md +61 -0
- package/skills/terminal-workspace/SKILL.md +249 -0
- package/skills/terminal-workspace/components.md +36 -0
- package/skills/terminal-workspace/config-choices.md +69 -0
- package/skills/terminal-workspace/emulator-keys.md +48 -0
- package/skills/terminal-workspace/headless-clipboard.md +175 -0
- package/skills/terminal-workspace/turn-off-and-update.md +104 -0
- package/skills/test-conventions/SKILL.md +59 -0
- package/skills/visual-docs/SKILL.md +246 -0
- package/skills/writing/SKILL.md +231 -0
- package/skills/writing/examples.md +153 -0
- 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
|
+
}
|