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,330 @@
1
+ #!/usr/bin/env node
2
+ // opener.js - show a page from a VM in the browser on this laptop.
3
+ //
4
+ // The VM has no browser. This service is how a page it rendered reaches the
5
+ // one in front of the user. It listens on loopback, and the VM reaches it
6
+ // through its own reverse SSH tunnel on port 19989. That tunnel is a separate
7
+ // scheduled task from the one carrying the Playwriter relay, because
8
+ // ExitOnForwardFailure=yes fails the whole ssh over a single wedged port. One
9
+ // shared connection would let a stuck 19989 take the bridge down with it.
10
+ //
11
+ // It is deliberately NOT the bridge. The bridge drives a browser for an agent
12
+ // over the Chrome DevTools Protocol, attaches per tab, needs a manual arming
13
+ // click and lives in a separate Chrome profile. This hands a page to the
14
+ // user's DEFAULT browser with no click anywhere. See
15
+ // docs/adr/0006-document-opener-service.md.
16
+ //
17
+ // Node rather than PowerShell because the laptop already has Node for the
18
+ // relay, and because binding a socket needs no URL ACL reservation, so this
19
+ // runs as an ordinary user with no elevation.
20
+
21
+ 'use strict';
22
+
23
+ const http = require('http');
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const os = require('os');
27
+ const crypto = require('crypto');
28
+ const { execFile } = require('child_process');
29
+
30
+ const PORT = Number(process.env.ACSTACK_OPENER_PORT || 19989);
31
+ const HOME = process.env.LOCALAPPDATA || os.homedir();
32
+ const STATE = path.join(HOME, 'playwriter-bridge');
33
+ const DOCS = path.join(STATE, 'docs');
34
+ const ASSETS = path.join(STATE, 'assets');
35
+ const TOKEN_FILE = path.join(STATE, 'opener.token');
36
+ const LOG_FILE = path.join(STATE, 'opener.log');
37
+
38
+ // A document whose tab has polled within this window is still on screen, so a
39
+ // re-render updates it in place instead of opening a second tab. This is the
40
+ // whole of "new tab, but reuse if the same file": liveness observed, never
41
+ // assumed. A tab the user closed stops polling and the next render opens
42
+ // afresh, which is the behaviour you want and cannot get by remembering that
43
+ // you once opened it.
44
+ const LIVE_MS = 8000;
45
+
46
+ // Documents are capability URLs: the id is unguessable, so the browser - which
47
+ // cannot send our auth header - is still the only thing that can fetch one.
48
+ // Deriving it from the source path rather than at random is what makes the id
49
+ // stable across renders, which is what lets a tab be reused at all.
50
+ const DOC_TTL_MS = 7 * 24 * 60 * 60 * 1000;
51
+
52
+ for (const d of [STATE, DOCS, ASSETS]) fs.mkdirSync(d, { recursive: true });
53
+
54
+ function log(msg) {
55
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
56
+ try { fs.appendFileSync(LOG_FILE, line); } catch { /* logging must never kill the service */ }
57
+ }
58
+
59
+ let TOKEN = '';
60
+ function loadToken() {
61
+ try {
62
+ TOKEN = fs.readFileSync(TOKEN_FILE, 'utf8').trim();
63
+ } catch {
64
+ TOKEN = '';
65
+ }
66
+ return TOKEN;
67
+ }
68
+ loadToken();
69
+
70
+ // Re-read on every check rather than caching: register-tasks.ps1 may write the
71
+ // token after this service is already running, and a service that needed a
72
+ // restart to notice would look like a broken bridge to the VM.
73
+ function authorised(req) {
74
+ const t = loadToken();
75
+ if (!t) return false;
76
+ const got = req.headers['x-acstack-token'];
77
+ if (typeof got !== 'string' || got.length !== t.length) return false;
78
+ return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(t));
79
+ }
80
+
81
+ function docId(key) {
82
+ const t = loadToken();
83
+ return crypto.createHmac('sha256', t || 'unset').update(key).digest('hex').slice(0, 24);
84
+ }
85
+
86
+ // id -> { version, title, lastPoll, opened }
87
+ const docs = new Map();
88
+
89
+ function docPath(id) { return path.join(DOCS, id + '.html'); }
90
+
91
+ function pruneDocs() {
92
+ const cutoff = Date.now() - DOC_TTL_MS;
93
+ let f;
94
+ try { f = fs.readdirSync(DOCS); } catch { return; }
95
+ for (const name of f) {
96
+ const p = path.join(DOCS, name);
97
+ try {
98
+ if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p);
99
+ } catch { /* a file that vanished under us needs no pruning */ }
100
+ }
101
+ }
102
+
103
+ function json(res, code, obj) {
104
+ const body = Buffer.from(JSON.stringify(obj));
105
+ res.writeHead(code, { 'content-type': 'application/json', 'content-length': body.length });
106
+ res.end(body);
107
+ }
108
+
109
+ // Hand the URL to whatever this machine considers the default browser. On
110
+ // Windows `start` is a cmd builtin, not a program, hence the shell; the empty
111
+ // string is start's title argument, which it otherwise takes from a quoted URL
112
+ // and then opens nothing. The other platforms are here because the laptop this
113
+ // runs on is not required to be Windows - only the bridge's relay is.
114
+ function openInBrowser(url) {
115
+ const [cmd, args] = process.platform === 'win32'
116
+ ? ['cmd.exe', ['/c', 'start', '', url]]
117
+ : process.platform === 'darwin'
118
+ ? ['open', [url]]
119
+ : ['xdg-open', [url]];
120
+ return new Promise((resolve) => {
121
+ execFile(cmd, args, { windowsHide: true }, (err) => {
122
+ if (err) log(`open failed for ${url}: ${err.message}`);
123
+ resolve(!err);
124
+ });
125
+ });
126
+ }
127
+
128
+ // key and title arrive hex-encoded. A key is an absolute file path and a title
129
+ // a file name, and either may hold characters a query string cannot carry
130
+ // intact. Hex has no escaping rules to get wrong at either end.
131
+ function unhex(v) {
132
+ if (typeof v !== 'string' || v.length === 0 || v.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(v)) return null;
133
+ return Buffer.from(v, 'hex').toString('utf8');
134
+ }
135
+
136
+ function runPowerShell(command) {
137
+ return new Promise((resolve) => {
138
+ execFile('powershell.exe',
139
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command],
140
+ { windowsHide: true, timeout: 30000 },
141
+ (err, stdout, stderr) => resolve({ ok: !err, stdout: String(stdout || ''), stderr: String(stderr || '') }));
142
+ });
143
+ }
144
+
145
+ // The page polls for its own version and reloads when it changes. Injected
146
+ // here rather than produced by the renderer on the VM: the renderer's job is
147
+ // a standalone document, and a document that phones home to this service is
148
+ // only meaningful while this service is serving it.
149
+ function injectReloader(html, id) {
150
+ const script = `
151
+ <script>
152
+ (function(){
153
+ var id=${JSON.stringify(id)},cur=null;
154
+ try{var y=sessionStorage.getItem('acstack-scroll-'+id);if(y)addEventListener('load',function(){scrollTo(0,+y)})}catch(e){}
155
+ addEventListener('beforeunload',function(){try{sessionStorage.setItem('acstack-scroll-'+id,String(scrollY))}catch(e){}});
156
+ setInterval(function(){
157
+ fetch('/doc/'+id+'/version',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
158
+ if(cur===null){cur=d.version;return}
159
+ if(d.version!==cur){try{sessionStorage.setItem('acstack-scroll-'+id,String(scrollY))}catch(e){}location.reload()}
160
+ }).catch(function(){});
161
+ },1500);
162
+ })();
163
+ </script>`;
164
+ return html.includes('</body>')
165
+ ? html.replace(/<\/body>/i, script + '\n</body>')
166
+ : html + script;
167
+ }
168
+
169
+ function readBody(req, limit) {
170
+ return new Promise((resolve, reject) => {
171
+ const chunks = [];
172
+ let size = 0;
173
+ req.on('data', (c) => {
174
+ size += c.length;
175
+ if (size > limit) { reject(new Error('body too large')); req.destroy(); return; }
176
+ chunks.push(c);
177
+ });
178
+ req.on('end', () => resolve(Buffer.concat(chunks)));
179
+ req.on('error', reject);
180
+ });
181
+ }
182
+
183
+ const server = http.createServer(async (req, res) => {
184
+ const url = new URL(req.url, 'http://127.0.0.1');
185
+ const p = url.pathname;
186
+
187
+ try {
188
+ // --- unauthenticated: the browser fetches these and cannot send a header.
189
+ // The document id is the capability, which is why it is an HMAC and not a
190
+ // counter.
191
+ let m = p.match(/^\/doc\/([0-9a-f]{24})$/);
192
+ if (m && req.method === 'GET') {
193
+ const id = m[1];
194
+ const entry = docs.get(id);
195
+ let html;
196
+ try { html = fs.readFileSync(docPath(id), 'utf8'); } catch { return json(res, 404, { error: 'no such document' }); }
197
+ if (entry) entry.lastPoll = Date.now();
198
+ const body = Buffer.from(injectReloader(html, id));
199
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': body.length, 'cache-control': 'no-store' });
200
+ return res.end(body);
201
+ }
202
+
203
+ m = p.match(/^\/doc\/([0-9a-f]{24})\/version$/);
204
+ if (m && req.method === 'GET') {
205
+ const entry = docs.get(m[1]);
206
+ if (!entry) return json(res, 404, { error: 'no such document' });
207
+ entry.lastPoll = Date.now();
208
+ return json(res, 200, { version: entry.version });
209
+ }
210
+
211
+ // Served from the copy the VM uploaded once. Without it, fall through to
212
+ // the CDN so a diagram still draws rather than the page half-rendering.
213
+ if (p === '/mermaid.js' && req.method === 'GET') {
214
+ const local = path.join(ASSETS, 'mermaid.min.js');
215
+ if (fs.existsSync(local)) {
216
+ const body = fs.readFileSync(local);
217
+ res.writeHead(200, { 'content-type': 'text/javascript', 'content-length': body.length });
218
+ return res.end(body);
219
+ }
220
+ res.writeHead(302, { location: 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js' });
221
+ return res.end();
222
+ }
223
+
224
+ // --- everything below speaks to the VM and must carry the token.
225
+ if (!authorised(req)) return json(res, 401, { error: 'bad or missing token' });
226
+
227
+ if (p === '/health' && req.method === 'GET') {
228
+ return json(res, 200, { opener: 1, port: PORT, docs: docs.size, mermaid: fs.existsSync(path.join(ASSETS, 'mermaid.min.js')) });
229
+ }
230
+
231
+ if (p === '/open' && req.method === 'POST') {
232
+ const body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}');
233
+ const target = String(body.url || '');
234
+ // Only schemes a browser should be handed. Without this the VM could ask
235
+ // for file:// or a custom protocol handler on this machine.
236
+ if (!/^https?:\/\//i.test(target)) return json(res, 400, { error: 'only http and https URLs' });
237
+ const ok = await openInBrowser(target);
238
+ log(`open url ${target} -> ${ok}`);
239
+ return json(res, ok ? 200 : 500, { opened: ok, url: target });
240
+ }
241
+
242
+ if (p === '/doc' && req.method === 'POST') {
243
+ const key = unhex(url.searchParams.get('key'));
244
+ if (!key) return json(res, 400, { error: 'key is required, hex-encoded' });
245
+ const title = unhex(url.searchParams.get('title')) || key;
246
+ const html = (await readBody(req, 32 * 1024 * 1024)).toString('utf8');
247
+ if (!html) return json(res, 400, { error: 'empty document' });
248
+
249
+ const id = docId(key);
250
+ fs.writeFileSync(docPath(id), html, 'utf8');
251
+ const entry = docs.get(id) || { version: 0, lastPoll: 0, opened: false };
252
+ entry.version += 1;
253
+ entry.title = title;
254
+ docs.set(id, entry);
255
+
256
+ const docUrl = `http://127.0.0.1:${PORT}/doc/${id}`;
257
+ // A tab that polled a moment ago is still open, and will reload itself.
258
+ // Opening again would leave the user with two tabs of the same document.
259
+ const live = entry.opened && (Date.now() - entry.lastPoll) < LIVE_MS;
260
+ let opened = false;
261
+ if (!live) {
262
+ opened = await openInBrowser(docUrl);
263
+ entry.opened = opened;
264
+ }
265
+ pruneDocs();
266
+ log(`doc ${title} -> ${docUrl} v${entry.version} ${live ? 'reused live tab' : 'opened new tab'}`);
267
+ return json(res, 200, { url: docUrl, version: entry.version, reused: live, opened });
268
+ }
269
+
270
+ if (p === '/asset/mermaid.js' && req.method === 'POST') {
271
+ const body = await readBody(req, 16 * 1024 * 1024);
272
+ fs.writeFileSync(path.join(ASSETS, 'mermaid.min.js'), body);
273
+ log(`cached mermaid.min.js (${body.length} bytes)`);
274
+ return json(res, 200, { cached: true, bytes: body.length });
275
+ }
276
+
277
+ // --- the two recoveries the VM could not previously perform itself. Until
278
+ // now these were handed to a human to paste into PowerShell. Arming the
279
+ // extension is still not here, because it is a click in a third-party
280
+ // extension and no amount of plumbing can press it.
281
+ if (p === '/bridge/restart' && req.method === 'POST') {
282
+ const body = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}');
283
+ const what = String(body.what || 'both');
284
+ // The two forwards are separate scheduled tasks - Playwriter-Tunnel-VM<n>
285
+ // carries the relay's 19988, Playwriter-OpenerTunnel-VM<n> the opener's
286
+ // 19989 - because ExitOnForwardFailure means one wedged port on a shared
287
+ // connection would take both down. So "restart the tunnel" is two
288
+ // patterns: 'Playwriter-Tunnel*' does not match the opener's task name,
289
+ // and asking for it alone would leave the opener's forward dead and the
290
+ // VM with no way to reach a browser.
291
+ // 'both' is the relay and both tunnels, never everything under the
292
+ // prefix: the opener itself runs as Playwriter-Opener, so 'Playwriter-*'
293
+ // would stop the process serving this very request. The VM would get no
294
+ // reply, and the one service that can restart anything would be down.
295
+ const patterns = what === 'relay' ? ['Playwriter-Relay*']
296
+ : what === 'tunnel' ? ['Playwriter-Tunnel*', 'Playwriter-OpenerTunnel*']
297
+ : ['Playwriter-Relay', 'Playwriter-Tunnel*', 'Playwriter-OpenerTunnel*'];
298
+ const taskNames = patterns.map((x) => `'${x}'`).join(',');
299
+ const r = await runPowerShell(
300
+ `Get-ScheduledTask -TaskName ${taskNames} -ErrorAction SilentlyContinue | ForEach-Object { Stop-ScheduledTask -TaskName $_.TaskName -ErrorAction SilentlyContinue; Start-ScheduledTask -TaskName $_.TaskName; $_.TaskName }`);
301
+ log(`bridge restart ${what}: ok=${r.ok}`);
302
+ return json(res, r.ok ? 200 : 500, { restarted: r.stdout.trim().split(/\r?\n/).filter(Boolean), error: r.ok ? null : r.stderr.trim() });
303
+ }
304
+
305
+ if (p === '/bridge/health' && req.method === 'GET') {
306
+ const r = await runPowerShell(
307
+ `Get-ScheduledTask -TaskName 'Playwriter-*' | ForEach-Object { "$($_.TaskName)=$($_.State)" }`);
308
+ const tasks = {};
309
+ for (const line of r.stdout.split(/\r?\n/)) {
310
+ const i = line.indexOf('=');
311
+ if (i > 0) tasks[line.slice(0, i).trim()] = line.slice(i + 1).trim();
312
+ }
313
+ return json(res, 200, { tasks });
314
+ }
315
+
316
+ return json(res, 404, { error: 'no such endpoint' });
317
+ } catch (err) {
318
+ log(`error handling ${req.method} ${p}: ${err.message}`);
319
+ return json(res, 500, { error: err.message });
320
+ }
321
+ });
322
+
323
+ server.listen(PORT, '127.0.0.1', () => {
324
+ log(`opener listening on 127.0.0.1:${PORT} (token ${loadToken() ? 'present' : 'MISSING'})`);
325
+ });
326
+
327
+ server.on('error', (err) => {
328
+ log(`listen failed: ${err.message}`);
329
+ process.exit(1);
330
+ });
@@ -0,0 +1,63 @@
1
+ # opener.ps1 - ACStack document opener, with auto-reconnect.
2
+ #
3
+ # Runs opener.js (Node) in a loop, the same shape relay.ps1 uses for the
4
+ # Playwriter relay. Intended to be invoked through launcher.exe by a scheduled
5
+ # task registered by register-tasks.ps1. Logs to
6
+ # %LOCALAPPDATA%\playwriter-bridge\opener-supervisor.log.
7
+ #
8
+ # The opener is what shows a page from a VM in this machine's default
9
+ # browser. It is not the bridge - see docs/adr/0006-document-opener-service.md
10
+ # for why those are two services and not one.
11
+ #
12
+ # Console hiding is handled by launcher.exe (spawns PowerShell with
13
+ # CREATE_NO_WINDOW). Nothing to do here.
14
+
15
+ $ErrorActionPreference = "Continue"
16
+
17
+ $logDir = Join-Path $env:LOCALAPPDATA "playwriter-bridge"
18
+ New-Item -Path $logDir -ItemType Directory -Force | Out-Null
19
+ $logFile = Join-Path $logDir "opener-supervisor.log"
20
+ $tokenFile = Join-Path $logDir "opener.token"
21
+
22
+ function Write-Log {
23
+ param([string]$Message)
24
+ "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Message" | Out-File -Append -FilePath $logFile
25
+ }
26
+
27
+ # The service refuses every request from the VM without this, so generate it
28
+ # rather than starting a listener nobody can talk to. register-tasks.ps1 copies
29
+ # whatever is here to the VM; generating it here too means the service is never
30
+ # running tokenless, whichever ran first.
31
+ if (-not (Test-Path $tokenFile)) {
32
+ $bytes = New-Object byte[] 32
33
+ [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
34
+ $token = [System.BitConverter]::ToString($bytes).Replace('-', '').ToLower()
35
+ Set-Content -Path $tokenFile -Value $token -NoNewline -Encoding ascii
36
+ Write-Log "Generated opener token at $tokenFile"
37
+ }
38
+
39
+ $openerJs = Join-Path $PSScriptRoot "opener.js"
40
+ if (-not (Test-Path $openerJs)) {
41
+ Write-Log "FATAL: opener.js not found beside this script at $openerJs"
42
+ exit 1
43
+ }
44
+
45
+ function Resolve-NodeCommand {
46
+ $node = Get-Command node -ErrorAction SilentlyContinue
47
+ if (-not $node) {
48
+ throw "'node' is not on PATH. Install Node LTS - the Playwriter relay needs it too."
49
+ }
50
+ return $node.Source
51
+ }
52
+
53
+ while ($true) {
54
+ try {
55
+ $node = Resolve-NodeCommand
56
+ Write-Log "Starting opener ($node $openerJs)"
57
+ & $node $openerJs 2>&1 | Out-File -Append -FilePath $logFile
58
+ Write-Log "Opener exited (code $LASTEXITCODE), sleeping 5s"
59
+ } catch {
60
+ Write-Log "Error: $($_.Exception.Message). Sleeping 5s"
61
+ }
62
+ Start-Sleep -Seconds 5
63
+ }