peaks-loop 4.0.35 → 4.0.37

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 (128) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/peaks.js +71 -1
  5. package/dist/cli/cli-helpers.js +7 -0
  6. package/dist/cli/commands/_register.js +2 -0
  7. package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
  8. package/dist/cli/commands/best-practice-scan-command.js +67 -9
  9. package/dist/cli/commands/code-runtime-commands.d.ts +5 -2
  10. package/dist/cli/commands/code-runtime-commands.js +78 -7
  11. package/dist/cli/commands/core/doctor-command.d.ts +8 -0
  12. package/dist/cli/commands/core/doctor-command.js +44 -2
  13. package/dist/cli/commands/core/memory-command.js +5 -1
  14. package/dist/cli/commands/dispatch-commands.js +15 -3
  15. package/dist/cli/commands/dispatch-from-dag.js +17 -0
  16. package/dist/cli/commands/hooks-commands.js +10 -1
  17. package/dist/cli/commands/job-commands.js +107 -25
  18. package/dist/cli/commands/memory-commands.d.ts +24 -0
  19. package/dist/cli/commands/memory-commands.js +77 -10
  20. package/dist/cli/commands/request-commands.d.ts +8 -0
  21. package/dist/cli/commands/request-commands.js +23 -2
  22. package/dist/cli/commands/scan-commands.js +1 -1
  23. package/dist/cli/commands/sub-agent-commands.js +2 -0
  24. package/dist/cli/commands/wave-plan-commands.d.ts +24 -0
  25. package/dist/cli/commands/wave-plan-commands.js +93 -0
  26. package/dist/cli/commands/web-commands.d.ts +28 -0
  27. package/dist/cli/commands/web-commands.js +327 -0
  28. package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
  29. package/dist/cli/commands/web-lifecycle-commands.js +321 -0
  30. package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
  31. package/dist/services/best-practice/scan-orchestrator.js +14 -5
  32. package/dist/services/code/orchestrator-can-do.js +27 -4
  33. package/dist/services/context/build-dispatch-system-prompt.d.ts +66 -9
  34. package/dist/services/context/build-dispatch-system-prompt.js +132 -17
  35. package/dist/services/context/context-audit-hint.d.ts +79 -0
  36. package/dist/services/context/context-audit-hint.js +150 -0
  37. package/dist/services/context/context-audit.d.ts +100 -0
  38. package/dist/services/context/context-audit.js +322 -0
  39. package/dist/services/context/summary-view.d.ts +54 -0
  40. package/dist/services/context/summary-view.js +114 -0
  41. package/dist/services/dispatch/file-overlap-wave-planner.d.ts +70 -0
  42. package/dist/services/dispatch/file-overlap-wave-planner.js +119 -0
  43. package/dist/services/dispatch/session-capsule.d.ts +23 -0
  44. package/dist/services/dispatch/session-capsule.js +56 -0
  45. package/dist/services/dispatch/slice-dag.d.ts +9 -0
  46. package/dist/services/dispatch/slice-dag.js +9 -1
  47. package/dist/services/dispatch/test-tool-detection.d.ts +12 -1
  48. package/dist/services/dispatch/test-tool-detection.js +14 -13
  49. package/dist/services/hooks/auto-compact-hook-install.js +10 -1
  50. package/dist/services/hooks/write-gate.js +88 -0
  51. package/dist/services/ide/adapters/claude-code-adapter.d.ts +10 -0
  52. package/dist/services/ide/adapters/claude-code-adapter.js +20 -1
  53. package/dist/services/ide/ide-types.d.ts +15 -0
  54. package/dist/services/lint/detect-eslint.d.ts +2 -0
  55. package/dist/services/lint/detect-eslint.js +23 -9
  56. package/dist/services/lint/npx-resolver.d.ts +6 -0
  57. package/dist/services/lint/npx-resolver.js +38 -14
  58. package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +5 -0
  59. package/dist/services/memory/project-memory-service/parsers/frontmatter.js +55 -5
  60. package/dist/services/release/version-precheck-service.js +9 -2
  61. package/dist/services/scan/file-size-scan.d.ts +29 -0
  62. package/dist/services/scan/file-size-scan.js +63 -0
  63. package/dist/services/session/caller-binding-service.d.ts +24 -0
  64. package/dist/services/session/caller-binding-service.js +34 -0
  65. package/dist/services/session/getSessionDir.js +15 -10
  66. package/dist/services/skills/hooks-codegate-superpowers.d.ts +33 -0
  67. package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
  68. package/dist/services/skills/hooks-settings-service.d.ts +10 -0
  69. package/dist/services/skills/hooks-settings-service.js +152 -61
  70. package/dist/services/slice/slice-check-service.d.ts +14 -0
  71. package/dist/services/slice/slice-check-service.js +110 -50
  72. package/dist/services/slice/slice-check-types.d.ts +12 -7
  73. package/dist/services/slice/slice-check-types.js +8 -3
  74. package/dist/services/slice/slice-decompose-runners.js +24 -21
  75. package/dist/services/sop/sop-check-service.js +12 -1
  76. package/dist/services/web/bounded-output.d.ts +34 -0
  77. package/dist/services/web/bounded-output.js +68 -0
  78. package/dist/services/web/browser-acquire.d.ts +14 -0
  79. package/dist/services/web/browser-acquire.js +84 -0
  80. package/dist/services/web/browser-session-manager.d.ts +111 -0
  81. package/dist/services/web/browser-session-manager.js +413 -0
  82. package/dist/services/web/daemon-entry.d.ts +1 -0
  83. package/dist/services/web/daemon-entry.js +65 -0
  84. package/dist/services/web/daemon-registry.d.ts +42 -0
  85. package/dist/services/web/daemon-registry.js +164 -0
  86. package/dist/services/web/daemon-supervisor.d.ts +144 -0
  87. package/dist/services/web/daemon-supervisor.js +455 -0
  88. package/dist/services/web/playwright-loader.d.ts +89 -0
  89. package/dist/services/web/playwright-loader.js +253 -0
  90. package/dist/services/web/snapshot-pruner.d.ts +48 -0
  91. package/dist/services/web/snapshot-pruner.js +241 -0
  92. package/dist/services/web/untrusted-envelope.d.ts +27 -0
  93. package/dist/services/web/untrusted-envelope.js +44 -0
  94. package/dist/services/web/web-artifact-paths.d.ts +79 -0
  95. package/dist/services/web/web-artifact-paths.js +163 -0
  96. package/dist/services/web/web-client.d.ts +19 -0
  97. package/dist/services/web/web-client.js +55 -0
  98. package/dist/services/web/web-daemon-service.d.ts +38 -0
  99. package/dist/services/web/web-daemon-service.js +416 -0
  100. package/dist/services/web/web-fallback.d.ts +70 -0
  101. package/dist/services/web/web-fallback.js +121 -0
  102. package/dist/services/web/web-install-service.d.ts +91 -0
  103. package/dist/services/web/web-install-service.js +346 -0
  104. package/dist/services/web/web-login-profile.d.ts +89 -0
  105. package/dist/services/web/web-login-profile.js +612 -0
  106. package/dist/services/web/web-login-staging.d.ts +27 -0
  107. package/dist/services/web/web-login-staging.js +173 -0
  108. package/dist/services/web/web-protocol.d.ts +58 -0
  109. package/dist/services/web/web-protocol.js +58 -0
  110. package/dist/services/web/web-status-report.d.ts +33 -0
  111. package/dist/services/web/web-status-report.js +47 -0
  112. package/dist/services/workspace/claude-settings-template.d.ts +41 -5
  113. package/dist/services/workspace/claude-settings-template.js +116 -64
  114. package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
  115. package/dist/services/workspace/workspace-service.js +33 -0
  116. package/package.json +5 -5
  117. package/scripts/copy-templates.mjs +12 -0
  118. package/scripts/sync-version.mjs +20 -0
  119. package/skills/bee/peaks-qa/SKILL.md +2 -0
  120. package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +12 -0
  121. package/skills/bee/peaks-rd/SKILL.md +2 -0
  122. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +14 -0
  123. package/skills/bee/peaks-txt/SKILL.md +2 -0
  124. package/skills/bee/peaks-ui/SKILL.md +2 -0
  125. package/skills/peaks-code/SKILL.md +18 -0
  126. package/skills/peaks-code/references/browser-workflow.md +10 -1
  127. package/skills/peaks-code/references/context-governance.md +29 -0
  128. package/skills/peaks-doctor/SKILL.md +2 -0
@@ -0,0 +1,416 @@
1
+ /**
2
+ * The `peaks web` daemon: loopback HTTP server, op routing, shutdown
3
+ * (slice S2, file 16).
4
+ *
5
+ * One daemon per `(projectRoot, sessionId)` (design §10.2). It binds
6
+ * `127.0.0.1:0` — an OS-assigned port, loopback only — and writes
7
+ * `web/daemon/daemon.json` with pid/port/token/version (tech-doc §1.3).
8
+ *
9
+ * Two deliberate properties:
10
+ *
11
+ * - **Chromium is acquired lazily**, on the first op that needs a page. A
12
+ * daemon that launched a browser at boot would make `peaks web status` and
13
+ * a cold `stop` pay ~700 MB and a process per session for a session
14
+ * that may never touch a page — and would make AC6's "no browser survives
15
+ * the session" unverifiable, because booting would already have started
16
+ * one.
17
+ * - **`routeOp` is exported and takes the manager as an argument**, so the
18
+ * whole op surface is testable without a listening socket.
19
+ */
20
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
21
+ import { createServer } from 'node:http';
22
+ import { getErrorMessage } from 'peaks-loop-shared/result';
23
+ import { acquireChromium } from './browser-acquire.js';
24
+ import { boundedTeardownStep, BrowserSessionManager } from './browser-session-manager.js';
25
+ import { removeDaemonInfo, writeDaemonInfo } from './daemon-registry.js';
26
+ import { PROTOCOL_VERSION } from './web-protocol.js';
27
+ /** Loopback only: the bearer token is the second lock, not the only one. */
28
+ export const DAEMON_HOST = '127.0.0.1';
29
+ /**
30
+ * Largest `/op` body we will buffer. Args are a URL, a selector and a
31
+ * dispatch id; 64 KiB is orders of magnitude above the real payload and stops
32
+ * an unrelated local process from growing the daemon's heap.
33
+ */
34
+ const MAX_REQUEST_BYTES = 64 * 1024;
35
+ /** The request's `dispatchId` when the caller sent none — same default as the CLI. */
36
+ const DEFAULT_DISPATCH_ID = 'current';
37
+ /**
38
+ * Start the daemon. Resolves once the server is listening and `daemon.json`
39
+ * exists, so the caller (and every poller in `ensureDaemon`) can treat the
40
+ * resolution as "reachable".
41
+ */
42
+ export async function startWebDaemon(config) {
43
+ const { projectRoot, sessionId } = config;
44
+ const token = randomBytes(32).toString('hex');
45
+ let browser = null;
46
+ let manager = null;
47
+ let shutdown = null;
48
+ /** Set synchronously by `close()` before it awaits anything. */
49
+ let shuttingDown = false;
50
+ /**
51
+ * The first PERMANENT acquisition failure, remembered for the daemon's whole
52
+ * lifetime.
53
+ *
54
+ * `managerFor` used to retry on every op, and the `MISSING_EXECUTABLE` branch
55
+ * of `acquireChromium` re-ran `playwright install chromium` — a blocking,
56
+ * network-touching `spawnSync`. One attempt per daemon lifetime, then fail
57
+ * fast, is still right for a failure that cannot heal (`PLAYWRIGHT_NOT_
58
+ * RESOLVABLE`: nothing on this machine holds the pin).
59
+ *
60
+ * It is NOT right for a failure the next minute can fix (R5). `WEB_INSTALL_
61
+ * BUSY` is "someone else is downloading right now"; `WEB_INSTALL_REQUIRED` is
62
+ * "the user has not run `peaks web install` yet". Latching either poisoned the
63
+ * daemon for its whole life: after the CLI's install finished successfully,
64
+ * every browser op still failed until the daemon was stopped. Those codes are
65
+ * re-attempted instead — and re-attempting is now cheap, because acquisition
66
+ * spawns nothing (R3).
67
+ */
68
+ let acquireFailure = null;
69
+ /**
70
+ * The acquisition already under way, so two ops arriving together share one
71
+ * launch instead of both passing the `manager === null` check and starting a
72
+ * browser each — the loser's handle would be overwritten and never closed.
73
+ */
74
+ let acquiring = null;
75
+ const managerFor = async () => {
76
+ if (acquireFailure !== null) {
77
+ throw acquireFailure;
78
+ }
79
+ if (manager === null) {
80
+ // The shutdown check lives INSIDE the shared promise, so it runs once for
81
+ // every caller waiting on it.
82
+ acquiring ??= (async () => {
83
+ let acquired;
84
+ try {
85
+ acquired = await acquireChromium();
86
+ }
87
+ catch (error) {
88
+ // Only a failure that cannot heal is latched; a transient one is
89
+ // re-attempted on the next op (R5, see the field's docstring).
90
+ if (!isTransientAcquireFailure(error)) {
91
+ acquireFailure = error;
92
+ }
93
+ throw error;
94
+ }
95
+ if (shuttingDown) {
96
+ // `close()` ran while chromium was launching, so it snapshotted
97
+ // `browser === null` and would never close this one. Without this the
98
+ // op returns into a daemon that is about to `process.exit(0)`, and
99
+ // the fresh chromium outlives it — AC6's false pass.
100
+ await acquired.browser.close();
101
+ throw new Error('WEB_DAEMON_SHUTTING_DOWN: the daemon is closing; the acquired browser was closed');
102
+ }
103
+ return acquired;
104
+ })();
105
+ let acquired;
106
+ try {
107
+ acquired = await acquiring;
108
+ }
109
+ finally {
110
+ // Released on FAILURE too. Leaving a rejected promise in place meant
111
+ // every later op awaited it and re-threw the same error without ever
112
+ // retrying, so a transient failure was permanent in practice whatever
113
+ // the latch did (R5).
114
+ acquiring = null;
115
+ }
116
+ browser = acquired.browser;
117
+ manager = new BrowserSessionManager(acquired.browser, { projectRoot, sessionId });
118
+ }
119
+ return manager;
120
+ };
121
+ const close = async () => {
122
+ shuttingDown = true;
123
+ if (shutdown !== null) {
124
+ return shutdown;
125
+ }
126
+ shutdown = (async () => {
127
+ const closed = manager === null
128
+ ? { closedContexts: 0, stateWriteFailures: [] }
129
+ : await manager.closeAll();
130
+ if (browser !== null) {
131
+ // Closing the browser ends the chromium process. Without this the
132
+ // daemon could record a clean stop while the browser outlived it —
133
+ // the exact false pass AC6 is written against.
134
+ await boundedTeardownStep(browser.close(), 'browser.close').catch(reportTeardownFailure);
135
+ }
136
+ await boundedTeardownStep(stopListening(server), 'stopListening').catch(reportTeardownFailure);
137
+ removeDaemonInfo(projectRoot, sessionId);
138
+ return closed;
139
+ })();
140
+ return shutdown;
141
+ };
142
+ const server = createServer((request, response) => {
143
+ void handleRequest(request, response).catch((error) => {
144
+ sendJson(response, 500, failureResponse(error));
145
+ });
146
+ });
147
+ async function handleRequest(request, response) {
148
+ const url = request.url ?? '';
149
+ // `/health` is unauthenticated by contract (tech-doc §1.3): it answers
150
+ // liveness, and the caller already proved ownership by reading daemon.json.
151
+ if (request.method === 'GET' && url === '/health') {
152
+ sendJson(response, 200, { ok: true });
153
+ return;
154
+ }
155
+ if (request.method !== 'POST' || url !== '/op') {
156
+ sendJson(response, 404, failureResponse(new Error('WEB_DAEMON_NOT_FOUND: no such endpoint')));
157
+ return;
158
+ }
159
+ if (!isAuthorized(request, token)) {
160
+ sendJson(response, 401, failureResponse(new Error('WEB_DAEMON_UNAUTHORIZED: bad or missing bearer token')));
161
+ return;
162
+ }
163
+ const request_ = parseOpRequest(await readBody(request));
164
+ if (request_ === null) {
165
+ sendJson(response, 400, failureResponse(new Error('WEB_DAEMON_BAD_REQUEST: body is not a WebOpRequest')));
166
+ return;
167
+ }
168
+ if (request_.op === 'whoami') {
169
+ // The ownership proof `stopDaemon` needs, answered from this process's
170
+ // own identity. Handled here rather than in `routeOp` for the same reason
171
+ // `stop` is: it must answer without ever acquiring a browser, and only the
172
+ // daemon knows who it is. It sits behind the bearer check above, which is
173
+ // what makes it evidence and `/health` not.
174
+ sendJson(response, 200, succeeded({ pid: process.pid, projectRoot, sessionId }));
175
+ return;
176
+ }
177
+ if (request_.op === 'stop') {
178
+ // Answer FIRST, then tear down: the caller must learn that the stop was
179
+ // accepted even though the socket is about to disappear.
180
+ sendJson(response, 200, {
181
+ ok: true,
182
+ data: null,
183
+ code: null,
184
+ message: null,
185
+ warnings: [],
186
+ nextActions: []
187
+ });
188
+ response.once('finish', () => {
189
+ void close()
190
+ .catch((error) => {
191
+ process.stderr.write(`peaks web: daemon shutdown failed: ${getErrorMessage(error)}\n`);
192
+ })
193
+ .then(() => {
194
+ // Re-raise the signal instead of exiting here: `daemon-entry.ts`'s
195
+ // handler is the single place that ends the process, so the
196
+ // graceful HTTP path and an OS signal take the same route. In a
197
+ // test (no listener) this is a no-op and the teardown above is
198
+ // what the assertions see.
199
+ process.emit('SIGTERM');
200
+ });
201
+ });
202
+ return;
203
+ }
204
+ sendJson(response, 200, await routeOp(request_.op, request_.args, managerFor));
205
+ }
206
+ const port = await listen(server);
207
+ writeDaemonInfo(projectRoot, sessionId, {
208
+ protocolVersion: PROTOCOL_VERSION,
209
+ pid: process.pid,
210
+ port,
211
+ token,
212
+ version: config.version,
213
+ projectRoot,
214
+ sessionId,
215
+ startedAt: new Date().toISOString()
216
+ });
217
+ // One line per boot, on the daemon's stderr (= `daemon.log`). It is the only
218
+ // surviving record of how many daemons have started for this session:
219
+ // `daemon.json` holds the LAST writer, so a second, racing daemon would
220
+ // otherwise leave no trace beyond an orphaned port.
221
+ process.stderr.write(`peaks web daemon listening on ${DAEMON_HOST}:${String(port)} (pid ${String(process.pid)}, session ${sessionId})\n`);
222
+ return { port, token, close };
223
+ }
224
+ /**
225
+ * Serve one op. Exported separately from the server so every verb's payload
226
+ * shape and failure code are unit-testable without a socket.
227
+ *
228
+ * The manager arrives as a PROVIDER, not a value: acquiring it is what launches
229
+ * chromium, so it must happen only for ops that actually need a page. An op the
230
+ * daemon does not serve (`install`, `login`, a typo) must answer without
231
+ * starting a browser at all.
232
+ */
233
+ export async function routeOp(op, args, managerFor) {
234
+ const dispatchId = stringArg(args['dispatchId']) || DEFAULT_DISPATCH_ID;
235
+ try {
236
+ switch (op) {
237
+ case 'open': {
238
+ const manager = await managerFor();
239
+ // The ONLY verb that accepts a profile (design §2). The name is passed
240
+ // down raw and validated again in the manager: it arrives off the wire,
241
+ // so the CLI's own check is not evidence here (same rule as S1's
242
+ // `assertUnder` / sid-slug pair). A mistyped optional arg means "not
243
+ // provided", never `[object Object]` — like every other optional arg.
244
+ return succeeded(await manager.open(dispatchId, stringArg(args['url']), optionalArg(args['profile'])));
245
+ }
246
+ case 'text': {
247
+ const manager = await managerFor();
248
+ return succeeded(await manager.text(dispatchId, optionalArg(args['selector'])));
249
+ }
250
+ case 'snap': {
251
+ const manager = await managerFor();
252
+ return succeeded(await manager.snap(dispatchId, optionalArg(args['selector'])));
253
+ }
254
+ case 'click': {
255
+ const manager = await managerFor();
256
+ return succeeded(await manager.click(dispatchId, stringArg(args['selector'])));
257
+ }
258
+ case 'shot': {
259
+ const manager = await managerFor();
260
+ return succeeded(await manager.shot(dispatchId, optionalArg(args['selector'])));
261
+ }
262
+ case 'metrics': {
263
+ const manager = await managerFor();
264
+ return succeeded(await manager.metrics(dispatchId));
265
+ }
266
+ default:
267
+ // `install` / `login` are S3/S4's; the slot exists so they can be
268
+ // added here as one `case` each rather than by reshaping the router.
269
+ return failureResponse(new Error(`WEB_OP_UNSUPPORTED: the daemon does not serve '${op}' yet`));
270
+ }
271
+ }
272
+ catch (error) {
273
+ // Covers both the op itself and chromium acquisition (a missing download,
274
+ // a resolvable-but-broken playwright).
275
+ return failureResponse(error);
276
+ }
277
+ }
278
+ /**
279
+ * The failure codes that mean "try again later", not "this can never work".
280
+ *
281
+ * `acquireChromium` raises the first when the browser simply is not installed
282
+ * yet and the second when another process holds the install lock — both are
283
+ * facts about this minute, not about this machine (R5). Everything else
284
+ * (`PLAYWRIGHT_NOT_RESOLVABLE`, a broken package) is permanent for the daemon's
285
+ * lifetime and is latched.
286
+ */
287
+ const TRANSIENT_ACQUIRE_FAILURE_RE = /^(WEB_INSTALL_REQUIRED|WEB_INSTALL_BUSY|WEB_INSTALL_TIMEOUT)\b/;
288
+ function isTransientAcquireFailure(error) {
289
+ return TRANSIENT_ACQUIRE_FAILURE_RE.test(getErrorMessage(error));
290
+ }
291
+ /**
292
+ * A bounded teardown step that ran out of budget must not abort the rest of the
293
+ * teardown — the record removal and the exit are what stop a wedged daemon from
294
+ * becoming invisible — but it must not be silent either, so it says so on the
295
+ * daemon's stderr (= `daemon.log`).
296
+ */
297
+ function reportTeardownFailure(error) {
298
+ process.stderr.write(`peaks web daemon: teardown step failed: ${getErrorMessage(error)}\n`);
299
+ }
300
+ /** `{ok:true}` with the op payload as `data`. */
301
+ function succeeded(data) {
302
+ return { ok: true, data, code: null, message: null, warnings: [], nextActions: [] };
303
+ }
304
+ /**
305
+ * Map a thrown error onto the envelope. S1's modules signal their failure code
306
+ * as a `CODE: detail` prefix, so the code survives the trip to the CLI instead
307
+ * of collapsing every daemon failure into one opaque `WEB_OP_FAILED`.
308
+ */
309
+ function failureResponse(error) {
310
+ const message = getErrorMessage(error);
311
+ const match = /^([A-Z][A-Z0-9_]{0,63}):\s*([\s\S]*)$/.exec(message);
312
+ return {
313
+ ok: false,
314
+ data: null,
315
+ code: match?.[1] ?? 'WEB_OP_FAILED',
316
+ message: match?.[2] ?? message,
317
+ warnings: [],
318
+ nextActions: []
319
+ };
320
+ }
321
+ /**
322
+ * Constant-time bearer check. `timingSafeEqual` needs equal byte lengths, so
323
+ * the length is compared first — a length mismatch is not a secret (the token
324
+ * is a fixed 64 hex chars) and the comparison is otherwise byte-for-byte.
325
+ */
326
+ function isAuthorized(request, token) {
327
+ const header = request.headers.authorization ?? '';
328
+ const presented = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
329
+ const expected = Buffer.from(token, 'utf8');
330
+ const actual = Buffer.from(presented, 'utf8');
331
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
332
+ }
333
+ /** Read the body, refusing anything past `MAX_REQUEST_BYTES`. */
334
+ async function readBody(request) {
335
+ const chunks = [];
336
+ let size = 0;
337
+ for await (const chunk of request) {
338
+ const bytes = chunk;
339
+ size += bytes.length;
340
+ if (size > MAX_REQUEST_BYTES) {
341
+ throw new Error(`WEB_DAEMON_BODY_TOO_LARGE: body exceeds ${String(MAX_REQUEST_BYTES)} bytes`);
342
+ }
343
+ chunks.push(bytes);
344
+ }
345
+ return Buffer.concat(chunks).toString('utf8');
346
+ }
347
+ /** `{op, args}` or `null` when the body is not shaped like a `WebOpRequest`. */
348
+ function parseOpRequest(body) {
349
+ let parsed;
350
+ try {
351
+ parsed = JSON.parse(body);
352
+ }
353
+ catch {
354
+ return null;
355
+ }
356
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
357
+ return null;
358
+ }
359
+ const { op, args } = parsed;
360
+ if (typeof op !== 'string' || op.length === 0) {
361
+ return null;
362
+ }
363
+ return {
364
+ op: op,
365
+ args: typeof args === 'object' && args !== null && !Array.isArray(args)
366
+ ? args
367
+ : {}
368
+ };
369
+ }
370
+ function stringArg(value) {
371
+ return typeof value === 'string' ? value : '';
372
+ }
373
+ /** An absent OR mistyped optional argument means "not provided", never `[object Object]`. */
374
+ function optionalArg(value) {
375
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
376
+ }
377
+ function sendJson(response, status, body) {
378
+ if (response.headersSent) {
379
+ response.end();
380
+ return;
381
+ }
382
+ const payload = JSON.stringify(body);
383
+ response.writeHead(status, {
384
+ 'content-type': 'application/json',
385
+ 'content-length': Buffer.byteLength(payload, 'utf8')
386
+ });
387
+ response.end(payload);
388
+ }
389
+ /** Bind, and report the OS-assigned port. */
390
+ function listen(server) {
391
+ return new Promise((settle, reject) => {
392
+ server.once('error', reject);
393
+ server.listen(0, DAEMON_HOST, () => {
394
+ server.removeListener('error', reject);
395
+ const address = server.address();
396
+ if (address === null || typeof address === 'string') {
397
+ reject(new Error('WEB_DAEMON_BIND_FAILED: no TCP address after listen'));
398
+ return;
399
+ }
400
+ settle(address.port);
401
+ });
402
+ });
403
+ }
404
+ /**
405
+ * Stop accepting work and release the socket. Idle keep-alive connections are
406
+ * dropped first: `fetch` keeps its connection open, and `server.close()` alone
407
+ * would wait for it until its own timeout.
408
+ */
409
+ function stopListening(server) {
410
+ return new Promise((settle) => {
411
+ server.close(() => {
412
+ settle();
413
+ });
414
+ server.closeAllConnections();
415
+ });
416
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The degradation chain, in one place (slice S3, file 19; AC5, tech-doc §5.4).
3
+ *
4
+ * Design §6 orders the chain: 1 local browser → 2 lazy download → 3 MCP
5
+ * fallback → 4 explicit error + install guidance. Orchestrator decision C3 is
6
+ * binding on how tiers 3 and 4 are represented here: they are **not a branch
7
+ * this process can take**. Whether `mcp__playwright__*` is available is harness
8
+ * state — the CLI is a subprocess and cannot see the caller's tool list — so
9
+ * the envelope carries BOTH the MCP fallback action and the install
10
+ * instruction, and the calling LLM picks. Naming the MCP tool explicitly, and
11
+ * saying where a screenshot taken through it lands, is what keeps the two
12
+ * options distinguishable; a blob that reads as one undifferentiated warning
13
+ * would defeat the purpose (C3's "consequence to keep").
14
+ *
15
+ * `installHint` is derived from `installCommandLine()`'s pin so the guidance
16
+ * and the command that is actually run cannot drift apart.
17
+ */
18
+ import { type ResultEnvelope } from 'peaks-loop-shared/result';
19
+ import type { WebOp } from './web-protocol.js';
20
+ /** 3 = fall back to MCP and/or install locally; 4 = nothing available. */
21
+ export type DegradationTier = 3 | 4;
22
+ /**
23
+ * The MCP tool that replaces each op on the fallback path.
24
+ *
25
+ * `status` / `stop` / `whoami` have no browser path at all, so they have no MCP
26
+ * equivalent and no degradation envelope: C2's matrix keeps them working
27
+ * (`status` reports the disabled flag, `stop` stops a process). The empty
28
+ * string is that "no fallback exists" answer, and `degradedEnvelope` — the only
29
+ * caller — says so rather than naming a tool that cannot do the job.
30
+ *
31
+ * `login` is empty for the SAME reason, and it is the one browser op where that
32
+ * is not obvious (S4 R3). No `mcp__playwright__*` tool persists a storage state,
33
+ * so `browser_navigate` was a dead end for the only verb whose whole purpose is
34
+ * persistence: an envelope that told a caller to call it would contradict its own
35
+ * `nextActions`. The machine-readable field now agrees with the human-readable
36
+ * one.
37
+ */
38
+ export declare const MCP_TOOL_FOR_OP: Record<WebOp, string>;
39
+ /**
40
+ * Design §6 tier-3 wording. Two approved artifacts spell it two ways — the
41
+ * design says 截图会落根目录, `qa/test-cases/peaks-web.md` §5 quotes
42
+ * 截图会落项目根目录 as "原文" — so both are carried, rather than picking one and
43
+ * failing the other's assertion. The second half says it in English for the
44
+ * same reason.
45
+ */
46
+ export declare const MCP_ROOT_DIR_WARNING = "MCP fallback screenshots land in the project root\uFF08\u622A\u56FE\u4F1A\u843D\u6839\u76EE\u5F55 / \u622A\u56FE\u4F1A\u843D\u9879\u76EE\u6839\u76EE\u5F55\uFF09";
47
+ /** The command a user or the LLM would run to get the local path back. */
48
+ export declare const INSTALL_HINT = "npx --yes --package playwright@1.63.0 -- playwright install chromium";
49
+ export interface DegradedData {
50
+ readonly tier: DegradationTier;
51
+ /** Empty when the op has no MCP equivalent (see `MCP_TOOL_FOR_OP`). */
52
+ readonly mcpTool: string;
53
+ readonly installHint: string;
54
+ /**
55
+ * The op's own arguments, carried so the fallback is *actionable* (R9).
56
+ * "Call `mcp__playwright__browser_navigate` instead" is not something a caller
57
+ * can execute without the URL; `browser_evaluate` for `text`/`metrics` is not
58
+ * something a caller can execute without the selector. Only string arguments
59
+ * are carried — they are the op surface, and dropping the rest keeps a
60
+ * `dispatchId`-shaped value from leaking into a fallback blob.
61
+ */
62
+ readonly args: Readonly<Record<string, string>>;
63
+ }
64
+ /**
65
+ * One envelope for the whole tier-3/4 branch (C3): `ok:false`, a code, the
66
+ * fallback tool, the install command, the op's own arguments, and the two things
67
+ * a caller must know — the local path was skipped and where the MCP path puts
68
+ * its screenshots.
69
+ */
70
+ export declare function degradedEnvelope(op: WebOp, reason: string, tier?: DegradationTier, opArgs?: Readonly<Record<string, unknown>>): ResultEnvelope<DegradedData>;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The degradation chain, in one place (slice S3, file 19; AC5, tech-doc §5.4).
3
+ *
4
+ * Design §6 orders the chain: 1 local browser → 2 lazy download → 3 MCP
5
+ * fallback → 4 explicit error + install guidance. Orchestrator decision C3 is
6
+ * binding on how tiers 3 and 4 are represented here: they are **not a branch
7
+ * this process can take**. Whether `mcp__playwright__*` is available is harness
8
+ * state — the CLI is a subprocess and cannot see the caller's tool list — so
9
+ * the envelope carries BOTH the MCP fallback action and the install
10
+ * instruction, and the calling LLM picks. Naming the MCP tool explicitly, and
11
+ * saying where a screenshot taken through it lands, is what keeps the two
12
+ * options distinguishable; a blob that reads as one undifferentiated warning
13
+ * would defeat the purpose (C3's "consequence to keep").
14
+ *
15
+ * `installHint` is derived from `installCommandLine()`'s pin so the guidance
16
+ * and the command that is actually run cannot drift apart.
17
+ */
18
+ import { fail } from 'peaks-loop-shared/result';
19
+ import { PLAYWRIGHT_VERSION_PIN } from './playwright-loader.js';
20
+ /**
21
+ * The MCP tool that replaces each op on the fallback path.
22
+ *
23
+ * `status` / `stop` / `whoami` have no browser path at all, so they have no MCP
24
+ * equivalent and no degradation envelope: C2's matrix keeps them working
25
+ * (`status` reports the disabled flag, `stop` stops a process). The empty
26
+ * string is that "no fallback exists" answer, and `degradedEnvelope` — the only
27
+ * caller — says so rather than naming a tool that cannot do the job.
28
+ *
29
+ * `login` is empty for the SAME reason, and it is the one browser op where that
30
+ * is not obvious (S4 R3). No `mcp__playwright__*` tool persists a storage state,
31
+ * so `browser_navigate` was a dead end for the only verb whose whole purpose is
32
+ * persistence: an envelope that told a caller to call it would contradict its own
33
+ * `nextActions`. The machine-readable field now agrees with the human-readable
34
+ * one.
35
+ */
36
+ export const MCP_TOOL_FOR_OP = {
37
+ open: 'mcp__playwright__browser_navigate',
38
+ text: 'mcp__playwright__browser_evaluate',
39
+ snap: 'mcp__playwright__browser_snapshot',
40
+ click: 'mcp__playwright__browser_click',
41
+ shot: 'mcp__playwright__browser_take_screenshot',
42
+ metrics: 'mcp__playwright__browser_evaluate',
43
+ login: '',
44
+ install: 'mcp__playwright__browser_install',
45
+ status: '',
46
+ stop: '',
47
+ whoami: ''
48
+ };
49
+ /**
50
+ * Design §6 tier-3 wording. Two approved artifacts spell it two ways — the
51
+ * design says 截图会落根目录, `qa/test-cases/peaks-web.md` §5 quotes
52
+ * 截图会落项目根目录 as "原文" — so both are carried, rather than picking one and
53
+ * failing the other's assertion. The second half says it in English for the
54
+ * same reason.
55
+ */
56
+ export const MCP_ROOT_DIR_WARNING = 'MCP fallback screenshots land in the project root(截图会落根目录 / 截图会落项目根目录)';
57
+ /** The command a user or the LLM would run to get the local path back. */
58
+ export const INSTALL_HINT = `npx --yes --package playwright@${PLAYWRIGHT_VERSION_PIN} -- playwright install chromium`;
59
+ /**
60
+ * `CODE: detail` — the same prefix convention `web-daemon-service`'s
61
+ * `failureResponse` parses, reused here so a caller can pass either a bare
62
+ * reason (the gate) or a coded failure (`WEB_INSTALL_FAILED: …`) and get the
63
+ * right `code` on the envelope without a second parameter.
64
+ */
65
+ const CODE_PREFIX_RE = /^([A-Z][A-Z0-9_]{0,63}):\s*/;
66
+ /**
67
+ * One envelope for the whole tier-3/4 branch (C3): `ok:false`, a code, the
68
+ * fallback tool, the install command, the op's own arguments, and the two things
69
+ * a caller must know — the local path was skipped and where the MCP path puts
70
+ * its screenshots.
71
+ */
72
+ export function degradedEnvelope(op, reason, tier = 3, opArgs = {}) {
73
+ const mcpTool = MCP_TOOL_FOR_OP[op];
74
+ const code = CODE_PREFIX_RE.exec(reason)?.[1] ?? (tier === 4 ? 'WEB_UNAVAILABLE' : 'WEB_DISABLED');
75
+ const detail = reason.replace(CODE_PREFIX_RE, '') || reason;
76
+ return {
77
+ ...fail(`peaks.web.${op}`, code, `peaks web ${op} did not run locally: ${detail}`, { tier, mcpTool, installHint: INSTALL_HINT, args: stringArgs(opArgs) }, nextActions(op, mcpTool)),
78
+ warnings: [`local browser skipped (${detail})`, MCP_ROOT_DIR_WARNING]
79
+ };
80
+ }
81
+ /** The op's own string arguments — `selector`, `url` — and nothing else. */
82
+ function stringArgs(opArgs) {
83
+ const args = {};
84
+ for (const [key, value] of Object.entries(opArgs)) {
85
+ if (typeof value === 'string' && value.length > 0) {
86
+ args[key] = value;
87
+ }
88
+ }
89
+ return args;
90
+ }
91
+ function nextActions(op, mcpTool) {
92
+ // `install`'s own next action is a RE-run with `--force`: the recovery path
93
+ // R6 names, and the only thing that helps when the installer exited 0 without
94
+ // landing the browser (R7).
95
+ if (op === 'install') {
96
+ return [
97
+ 'Re-run `peaks web install --force` to re-download the browser',
98
+ 'Or call mcp__playwright__browser_install instead'
99
+ ];
100
+ }
101
+ const install = 'Run `peaks web install` for the local path';
102
+ // `login` is the one browser op its MCP fallback cannot stand in for: no
103
+ // `mcp__playwright__*` tool persists a storage state, so naming
104
+ // `browser_navigate` would send the caller to a dead end for the only verb
105
+ // whose whole purpose is persistence (S4 R3). The gate is the real recovery.
106
+ if (op === 'login') {
107
+ return [
108
+ 'Unset PEAKS_WEB_DISABLED and re-run `peaks web login --profile <name>` — ' +
109
+ 'the MCP fallback cannot save a login profile',
110
+ install
111
+ ];
112
+ }
113
+ if (mcpTool === '') {
114
+ return [install];
115
+ }
116
+ return [
117
+ `Call ${mcpTool} directly instead (MCP fallback for \`peaks web ${op}\`; ` +
118
+ 'screenshots taken that way land in the project root)',
119
+ install
120
+ ];
121
+ }