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,413 @@
1
+ /**
2
+ * All Playwright operations for `peaks web` (slice S1, file 11).
3
+ *
4
+ * One browser process per session; one browser CONTEXT per dispatch (Q8). The
5
+ * context is what isolates cookies/localStorage between dispatches, which is
6
+ * why the per-dispatch `pw-profiles/<dispatchId>` convention stores a
7
+ * Playwright `storageState.json` and never a Chromium `userDataDir`
8
+ * (orchestrator decision C1 — `launchPersistentContext` would spawn one browser
9
+ * process per dispatch and violate Q8).
10
+ *
11
+ * Every write target is passed through `assertUnder` and every screenshot uses
12
+ * an explicit absolute `path`, so nothing can land in the project root (AC1).
13
+ *
14
+ * `open` may additionally name a user-level LOGIN PROFILE (design §2:
15
+ * `peaks web open <url> [--profile <name>]`, the only verb that takes one). That
16
+ * profile is **READ-ONLY here**: it is loaded into the dispatch's context and is
17
+ * never written back. Whether an automated browse should refresh a user-level
18
+ * credential file is an undecided design question, so this module does not
19
+ * decide it — see `contextFor`.
20
+ */
21
+ import { existsSync, mkdirSync, statSync } from 'node:fs';
22
+ import { dirname } from 'node:path';
23
+ import { getErrorMessage } from 'peaks-loop-shared/result';
24
+ import { capText, MAX_SNAP_BYTES, MAX_SNAP_DEPTH, MAX_TEXT_BYTES } from './bounded-output.js';
25
+ import { pruneAriaSnapshot, renderSnapshot } from './snapshot-pruner.js';
26
+ import { assertUnder, userWebProfilesDir, webContextStatePath, webDir, webProfilesDir, webShotPath } from './web-artifact-paths.js';
27
+ import { loginStorageStatePath, resolveProfileName } from './web-login-profile.js';
28
+ /**
29
+ * Core Web Vitals need observers registered BEFORE the page loads
30
+ * (orchestrator decision C4), so this is installed as a context init script
31
+ * rather than run after navigation.
32
+ */
33
+ const VITALS_INIT_SCRIPT = `(() => {
34
+ if (globalThis.__peaksWebVitals) return;
35
+ const vitals = (globalThis.__peaksWebVitals = { lcp: null, cls: 0, inp: null });
36
+ try {
37
+ new PerformanceObserver((list) => {
38
+ const entries = list.getEntries();
39
+ if (entries.length > 0) vitals.lcp = entries[entries.length - 1].startTime;
40
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
41
+ new PerformanceObserver((list) => {
42
+ for (const entry of list.getEntries()) {
43
+ if (!entry.hadRecentInput) vitals.cls += entry.value;
44
+ }
45
+ }).observe({ type: 'layout-shift', buffered: true });
46
+ new PerformanceObserver((list) => {
47
+ const entries = list.getEntries();
48
+ if (entries.length > 0) {
49
+ vitals.inp = Math.max(vitals.inp ?? 0, entries[entries.length - 1].duration);
50
+ }
51
+ }).observe({ type: 'event', buffered: true, durationThreshold: 16 });
52
+ } catch {
53
+ // Observer types unsupported by this engine: metrics stays unavailable.
54
+ }
55
+ })();`;
56
+ /**
57
+ * Total budget for one teardown, and the budget for any single step inside it.
58
+ *
59
+ * `stopDaemon` waits `STOP_EXIT_TIMEOUT_MS = 10 s` for the daemon to leave and
60
+ * then falls back to `SIGTERM`, which on Windows is `TerminateProcess`: it kills
61
+ * the daemon mid-teardown and orphans its chromium. An unbounded `closeAll` /
62
+ * `browser.close` therefore converts a SLOW teardown into a LEAKED browser — so
63
+ * every step is bounded, and the whole thing finishes inside the waiter.
64
+ *
65
+ * Retuned after the S3 acceptance run recorded
66
+ * `teardown step failed: WEB_TEARDOWN_TIMEOUT: browser.close did not finish
67
+ * within 1500 ms` on a loaded machine. Measured here: an idle `browser.close()`
68
+ * on the headless shell takes **102 ms**, so the old step budget left only ~15×
69
+ * on a quiet box and ran out when the machine was busy. The step budget is now
70
+ * 2 000 ms.
71
+ *
72
+ * The loop budget stays ABOVE the step budget — that ordering is a separate
73
+ * guarantee, tested by its own case: after ONE wedged step is abandoned, the
74
+ * loop must still reach the remaining contexts. So the constant that moved to
75
+ * pay for the larger step is the waiter, not the loop:
76
+ *
77
+ * loop 3 000 + 4 x step 2 000 = 11 000 ms < STOP_EXIT_TIMEOUT_MS = 15 000 ms
78
+ *
79
+ * (worst case: the loop's last iteration starts just under its deadline and
80
+ * runs two steps, then `browser.close`, then `stopListening`). The waiter polls,
81
+ * so a normal teardown returns as soon as the process exits and pays nothing for
82
+ * the larger ceiling; what it buys is that a slow-but-bounded teardown is no
83
+ * longer SIGTERM'd mid-way, which is what orphans a chromium (AC6). The failure
84
+ * stays visible either way: `reportTeardownFailure` writes every overrun to
85
+ * `daemon.log`, which is how this one was found.
86
+ */
87
+ const TEARDOWN_BUDGET_MS = 3_000;
88
+ const TEARDOWN_STEP_TIMEOUT_MS = 2_000;
89
+ /**
90
+ * The most distinct dispatch contexts one daemon will hold open.
91
+ *
92
+ * A `dispatchId` comes off the wire and there is deliberately no idle-exit
93
+ * (Q7), so an unbounded map would let a token-holder grow the daemon's memory
94
+ * one context at a time for its whole life. Past the cap the op is refused by
95
+ * name rather than silently evicting a context a live dispatch is still using.
96
+ */
97
+ const MAX_DISPATCH_CONTEXTS = 32;
98
+ /**
99
+ * Resolve with `work`, or reject once the step has taken longer than the step
100
+ * budget. The late settlement of `work` is consumed here, so a step that
101
+ * finishes after its deadline cannot become an unhandled rejection.
102
+ */
103
+ export function boundedTeardownStep(work, label) {
104
+ return new Promise((settle, reject) => {
105
+ const timer = setTimeout(() => {
106
+ reject(new Error(`WEB_TEARDOWN_TIMEOUT: ${label} did not finish within ${String(TEARDOWN_STEP_TIMEOUT_MS)} ms`));
107
+ }, TEARDOWN_STEP_TIMEOUT_MS);
108
+ work.then((value) => {
109
+ clearTimeout(timer);
110
+ settle(value);
111
+ }, (error) => {
112
+ clearTimeout(timer);
113
+ reject(error);
114
+ });
115
+ });
116
+ }
117
+ export class BrowserSessionManager {
118
+ browser;
119
+ projectRoot;
120
+ sessionId;
121
+ sessions = new Map();
122
+ constructor(browser, options) {
123
+ this.browser = browser;
124
+ this.projectRoot = options.projectRoot;
125
+ this.sessionId = options.sessionId;
126
+ }
127
+ /**
128
+ * The context for a dispatch, created on first use and reused afterwards.
129
+ *
130
+ * `profile` names a user-level login profile (`peaks web login --profile`) to
131
+ * build the context from. When given it REPLACES the dispatch's own state: a
132
+ * context holds one storage state, so "start from the profile" and "start from
133
+ * the per-dispatch state" are alternatives, not layers.
134
+ *
135
+ * The profile is **READ-ONLY**. This module reads it into the context and
136
+ * never writes, refreshes or trims it — write-back is an undecided design
137
+ * question (it would keep a login fresh without re-running `login`, and it
138
+ * would also let an automated browse silently rewrite a user-level credential
139
+ * file), so this slice does not decide it. `closeAll` still persists the
140
+ * dispatch's OWN `pw-profiles/<dispatchId>/storageState.json`; nothing under
141
+ * `~/.peaks/web-profiles/` is ever created or modified from here.
142
+ *
143
+ * The name is validated HERE and not only in the CLI, because it arrives off
144
+ * the wire: `resolveProfileName` is the SAME resolver `login` uses (folds to
145
+ * lower case, refuses a traversing name, a leading dot, a device name), and
146
+ * the read is anchored with `assertUnder`. A profile that does not exist is a
147
+ * NAMED failure — falling through to an unauthenticated context would answer
148
+ * "open this with my profile" with a logged-out browser and no signal.
149
+ */
150
+ async contextFor(dispatchId, rawProfile) {
151
+ // Resolved before the dedup check, so the value compared below is the
152
+ // canonical, ≤64-char name and never the raw text off the wire.
153
+ const profile = rawProfile === undefined ? null : resolveProfileName(rawProfile);
154
+ const existing = this.sessions.get(dispatchId);
155
+ if (existing !== undefined) {
156
+ if (profile !== null && profile !== existing.profile) {
157
+ // Not a silent wrong answer: this dispatch already has a context and it
158
+ // was NOT built from the profile just asked for, so reusing it would
159
+ // serve a request for one profile with a browser logged in as another
160
+ // (or as nobody).
161
+ throw new Error(`WEB_PROFILE_CONFLICT: dispatch ${dispatchId} already has a browser context ` +
162
+ (existing.profile === null
163
+ ? 'that was not created from a profile'
164
+ : `created from profile "${existing.profile}"`) +
165
+ `; it cannot be reopened as "${profile}" — use a fresh dispatch id or stop the daemon`);
166
+ }
167
+ return existing.context;
168
+ }
169
+ if (this.sessions.size >= MAX_DISPATCH_CONTEXTS) {
170
+ throw new Error(`WEB_DISPATCH_LIMIT: this daemon already holds ${String(MAX_DISPATCH_CONTEXTS)} dispatch contexts; ` +
171
+ 'stop the daemon to release them');
172
+ }
173
+ const statePath = webContextStatePath(this.projectRoot, this.sessionId, dispatchId);
174
+ // The state file lives under `pw-profiles/`, a SIBLING of `web/` — guarding
175
+ // it with `webDir` rejects every dispatch.
176
+ assertUnder(statePath, webProfilesDir(this.projectRoot, this.sessionId));
177
+ const profileStatePath = profile === null ? null : existingProfileStatePath(profile);
178
+ const context = await this.browser.newContext({
179
+ acceptDownloads: false,
180
+ ...(profileStatePath !== null
181
+ ? { storageState: profileStatePath }
182
+ : existsSync(statePath)
183
+ ? { storageState: statePath }
184
+ : {})
185
+ });
186
+ await context.addInitScript(VITALS_INIT_SCRIPT);
187
+ this.sessions.set(dispatchId, { context, profile, page: null });
188
+ return context;
189
+ }
190
+ async open(dispatchId, url, profile) {
191
+ assertNavigableUrl(url);
192
+ const page = await this.pageFor(dispatchId, profile);
193
+ await page.goto(url, { waitUntil: 'load' });
194
+ // `url()` after redirects and `title()` are both page-controlled.
195
+ return {
196
+ url: capText(page.url(), MAX_TEXT_BYTES).text,
197
+ title: capText(await page.title(), MAX_TEXT_BYTES).text
198
+ };
199
+ }
200
+ async text(dispatchId, selector) {
201
+ const page = await this.pageFor(dispatchId);
202
+ const raw = await page.locator(selector ?? 'body').innerText();
203
+ const capped = capText(raw, MAX_TEXT_BYTES);
204
+ return { text: capped.text, truncated: capped.truncated, droppedBytes: capped.droppedBytes };
205
+ }
206
+ /**
207
+ * Primary path is `locator.ariaSnapshotJSON()` (Playwright 1.63, our exact
208
+ * pin). We do NOT re-derive the tree from the YAML form: parsing YAML by
209
+ * indentation is the fragile option, and under an exact pin the JSON API is
210
+ * always present (tech-doc §4.1/§4.2). A missing API is an explicit error
211
+ * rather than a silent degradation.
212
+ */
213
+ async snap(dispatchId, selector) {
214
+ const page = await this.pageFor(dispatchId);
215
+ const locator = page.locator(selector ?? 'body');
216
+ const captureJson = locator.ariaSnapshotJSON?.bind(locator);
217
+ if (captureJson === undefined) {
218
+ throw new Error(`WEB_SNAP_UNSUPPORTED: locator.ariaSnapshotJSON is missing (peaks web pins playwright@1.63.0)`);
219
+ }
220
+ const raw = await captureJson({ mode: 'default', depth: MAX_SNAP_DEPTH });
221
+ const pruned = pruneAriaSnapshot(asAriaNodes(raw));
222
+ const capped = capText(renderSnapshot(pruned.nodes), MAX_SNAP_BYTES);
223
+ return {
224
+ snapshot: capped.text,
225
+ droppedNodes: pruned.droppedNodes,
226
+ depthCapped: pruned.depthCapped,
227
+ nodeCapped: pruned.nodeCapped,
228
+ truncated: capped.truncated,
229
+ droppedBytes: capped.droppedBytes
230
+ };
231
+ }
232
+ async click(dispatchId, selector) {
233
+ const page = await this.pageFor(dispatchId);
234
+ await page.locator(selector).click();
235
+ // The interpolated title is page-controlled; only the selector is ours.
236
+ return { result: capText(`clicked ${selector} (page: ${await page.title()})`, MAX_TEXT_BYTES).text };
237
+ }
238
+ /**
239
+ * Screenshot to an absolute path under `web/`. Always an explicit `path`:
240
+ * never the Playwright default, and never a Buffer we write ourselves
241
+ * (tech-doc §7.2 rule 3).
242
+ */
243
+ async shot(dispatchId, selector) {
244
+ const page = await this.pageFor(dispatchId);
245
+ const target = webShotPath(this.projectRoot, this.sessionId, shotTimestamp());
246
+ assertUnder(target, webDir(this.projectRoot, this.sessionId));
247
+ mkdirSync(dirname(target), { recursive: true });
248
+ if (selector === undefined) {
249
+ await page.screenshot({ path: target, type: 'png' });
250
+ }
251
+ else {
252
+ await page.locator(selector).screenshot({ path: target, type: 'png' });
253
+ }
254
+ return { path: target, bytes: statSync(target).size };
255
+ }
256
+ /**
257
+ * Core Web Vitals for the dispatch's page. Returns `available: false` with a
258
+ * reason — never fabricated zeros — when there is no observation window
259
+ * (orchestrator decision C4), and never echoes the page's object back: only
260
+ * the three contract keys, only finite numbers (R2).
261
+ */
262
+ async metrics(dispatchId) {
263
+ const session = this.sessions.get(dispatchId);
264
+ if (session === undefined || session.page === null) {
265
+ return { available: false, reason: 'no-observation-window', values: null };
266
+ }
267
+ const values = readVitals(await session.page.evaluate('globalThis.__peaksWebVitals ?? null'));
268
+ if (values === null) {
269
+ return { available: false, reason: 'no-observation-window', values: null };
270
+ }
271
+ return { available: true, reason: null, values };
272
+ }
273
+ /**
274
+ * Persist each dispatch's storage state, then close every context exactly
275
+ * once. A state file that cannot be written must not block shutdown — but it
276
+ * must be reported: a silent skip here kills S4's persistence with no signal
277
+ * anywhere (R1).
278
+ */
279
+ async closeAll() {
280
+ let closedContexts = 0;
281
+ const stateWriteFailures = [];
282
+ const deadline = Date.now() + TEARDOWN_BUDGET_MS;
283
+ for (const [dispatchId, session] of this.sessions) {
284
+ if (Date.now() >= deadline) {
285
+ // Out of budget: `browser.close()` below is what reaps the remaining
286
+ // contexts, and it must still get its turn before the caller's
287
+ // SIGTERM. `closedContexts` keeps counting only what really closed.
288
+ break;
289
+ }
290
+ const statePath = webContextStatePath(this.projectRoot, this.sessionId, dispatchId);
291
+ try {
292
+ assertUnder(statePath, webProfilesDir(this.projectRoot, this.sessionId));
293
+ mkdirSync(dirname(statePath), { recursive: true });
294
+ await boundedTeardownStep(session.context.storageState({ path: statePath }), `storageState for dispatch ${dispatchId}`);
295
+ }
296
+ catch (error) {
297
+ stateWriteFailures.push({ dispatchId, reason: getErrorMessage(error) });
298
+ }
299
+ try {
300
+ await boundedTeardownStep(session.context.close(), `context close for dispatch ${dispatchId}`);
301
+ closedContexts += 1;
302
+ }
303
+ catch {
304
+ // Already closed, or wedged past its budget: teardown must still reach
305
+ // the remaining dispatches and the final `browser.close()`.
306
+ }
307
+ }
308
+ this.sessions.clear();
309
+ return { closedContexts, stateWriteFailures };
310
+ }
311
+ async pageFor(dispatchId, profile) {
312
+ // Called on EVERY path, not only when the dispatch has no session yet:
313
+ // `contextFor` is already the dedup lookup (it returns the existing
314
+ // context), and reuse is where a profile mismatch has to be caught.
315
+ await this.contextFor(dispatchId, profile);
316
+ const active = this.sessions.get(dispatchId);
317
+ if (active === undefined) {
318
+ throw new Error(`WEB_CONTEXT_MISSING: no browser context for dispatch ${dispatchId}`);
319
+ }
320
+ if (active.page === null) {
321
+ active.page = await active.context.newPage();
322
+ }
323
+ return active.page;
324
+ }
325
+ }
326
+ /**
327
+ * The storage state a named profile loads: `~/.peaks/web-profiles/<name>/storageState.json`.
328
+ *
329
+ * READ-ONLY by construction: this function only ever READS the path it returns,
330
+ * and the module never writes one.
331
+ *
332
+ * `name` is already canonical (the caller ran `resolveProfileName`); the
333
+ * resolver inside `loginStorageStatePath` is idempotent on a canonical name, so
334
+ * the path literal stays in one place. The read is anchored with `assertUnder`
335
+ * the same way the write site is, and `resolveProfileName`'s own `assertUnder`
336
+ * has already rejected a name that climbs out of the profile root.
337
+ *
338
+ * A missing profile is a NAMED failure. The `existsSync`-guarded spread in
339
+ * `contextFor` is what it must never become: that pattern silently yields an
340
+ * unauthenticated context, which is the one answer a caller who asked for a
341
+ * profile must not be given.
342
+ */
343
+ function existingProfileStatePath(name) {
344
+ const statePath = loginStorageStatePath(name);
345
+ assertUnder(statePath, userWebProfilesDir());
346
+ if (!existsSync(statePath)) {
347
+ throw new Error(`WEB_PROFILE_NOT_FOUND: there is no login profile "${name}" at ${statePath} — create it ` +
348
+ `with \`peaks web login --profile ${name}\``);
349
+ }
350
+ return statePath;
351
+ }
352
+ /** `YYYYMMDDTHHMMSSmmmZ` — no colons, so the file name is Windows-safe (§7.1). */
353
+ function shotTimestamp(now = new Date()) {
354
+ return now.toISOString().replace(/[-:.]/g, '');
355
+ }
356
+ /**
357
+ * The aria payload as nodes. A shape mismatch is an explicit error, matching
358
+ * `snap`'s policy for a missing API: an empty-but-`ok` snapshot would be read
359
+ * as "the page is empty" rather than "the response was not a node array".
360
+ * An empty array stays valid — that is a genuinely empty page.
361
+ */
362
+ function asAriaNodes(raw) {
363
+ if (!Array.isArray(raw)) {
364
+ throw new Error(`WEB_SNAP_SHAPE_UNEXPECTED: ariaSnapshotJSON returned ${typeof raw}, not a node array`);
365
+ }
366
+ const nodes = raw.filter((node) => typeof node === 'object' && node !== null && typeof node.role === 'string');
367
+ if (nodes.length !== raw.length) {
368
+ throw new Error(`WEB_SNAP_SHAPE_UNEXPECTED: ariaSnapshotJSON returned ${raw.length - nodes.length} node(s) without a string role`);
369
+ }
370
+ return nodes;
371
+ }
372
+ /** The only vitals keys `metrics` reports (orchestrator decision C4's contract). */
373
+ const VITALS_KEYS = ['lcp', 'cls', 'inp'];
374
+ /**
375
+ * The page's vitals, read as DATA we bounded ourselves. `addInitScript`
376
+ * installs the object, but a page may replace it afterwards, so nothing is
377
+ * copied through: only C4's three keys, and only finite numbers. A payload
378
+ * without a single numeric metric is not an observation window at all.
379
+ */
380
+ function readVitals(raw) {
381
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
382
+ return null;
383
+ }
384
+ const source = raw;
385
+ const values = {};
386
+ let observed = false;
387
+ for (const key of VITALS_KEYS) {
388
+ const value = source[key];
389
+ const numeric = typeof value === 'number' && Number.isFinite(value);
390
+ values[key] = numeric ? value : null;
391
+ observed = observed || numeric;
392
+ }
393
+ return observed ? values : null;
394
+ }
395
+ /** The only schemes `open` will navigate to. */
396
+ const ALLOWED_URL_SCHEMES = new Set(['http:', 'https:']);
397
+ /**
398
+ * `page.goto` would follow `file:` (a local-disk read) and any loopback or
399
+ * cloud-metadata host, so the scheme is allowlisted at the argument — before a
400
+ * context exists. Host policy belongs to S3's disable gate, not here.
401
+ */
402
+ function assertNavigableUrl(raw) {
403
+ let parsed;
404
+ try {
405
+ parsed = new URL(raw);
406
+ }
407
+ catch {
408
+ throw new Error(`WEB_URL_INVALID: ${raw} is not an absolute URL`);
409
+ }
410
+ if (!ALLOWED_URL_SCHEMES.has(parsed.protocol)) {
411
+ throw new Error(`WEB_URL_SCHEME_REJECTED: ${parsed.protocol} is not http: or https:`);
412
+ }
413
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The `peaks web` daemon process entry (slice S2, file 15).
3
+ *
4
+ * This file IS the process: it is what `spawnDaemon` launches, so it must run
5
+ * under plain `node` (built tree) as well as under `tsx` (source tree). It is
6
+ * deliberately a thin shell — read the environment, start the service, register
7
+ * with the parent, wire the signals. Everything worth testing lives in
8
+ * `web-daemon-service.ts`.
9
+ *
10
+ * stdout and stderr are already redirected to `web/daemon/daemon.log` by
11
+ * `spawnDaemon`, which is why every diagnostic here is a plain stderr write and
12
+ * never a `console.log` to a terminal (tech-doc §7.2 rule 6).
13
+ */
14
+ import { getErrorMessage } from 'peaks-loop-shared/result';
15
+ import { CLI_VERSION } from 'peaks-loop-shared/version';
16
+ import { registerWithParent } from './daemon-supervisor.js';
17
+ import { webDir } from './web-artifact-paths.js';
18
+ import { startWebDaemon } from './web-daemon-service.js';
19
+ function fatal(reason) {
20
+ process.stderr.write(`peaks web daemon: ${reason}\n`);
21
+ process.exit(1);
22
+ }
23
+ async function main() {
24
+ const projectRoot = process.env['PEAKS_WEB_PROJECT_ROOT'] ?? '';
25
+ const sessionId = process.env['PEAKS_WEB_SESSION_ID'] ?? '';
26
+ if (projectRoot === '' || sessionId === '') {
27
+ fatal('PEAKS_WEB_PROJECT_ROOT and PEAKS_WEB_SESSION_ID are both required');
28
+ }
29
+ // The artifact dir is derived, never taken on trust: a mis-set env would make
30
+ // the daemon write outside this session's `web/` tree (AC1), and the failure
31
+ // would surface as a mystery `WEB_PATH_ESCAPE` much later.
32
+ const derivedArtifactDir = webDir(projectRoot, sessionId);
33
+ const declaredArtifactDir = process.env['PEAKS_WEB_ARTIFACT_DIR'] ?? '';
34
+ if (declaredArtifactDir !== derivedArtifactDir) {
35
+ fatal(`PEAKS_WEB_ARTIFACT_DIR ${JSON.stringify(declaredArtifactDir)} does not match this session's web dir ${JSON.stringify(derivedArtifactDir)}`);
36
+ }
37
+ const daemon = await startWebDaemon({ projectRoot, sessionId, version: CLI_VERSION });
38
+ // Q7: reuse the existing sub-agent shutdown registry, best-effort. Lifetime
39
+ // is register + parent kill + `peaks web stop` — there is no idle-exit.
40
+ registerWithParent(process.pid, process.env['PEAKS_DISPATCH_ID'] ?? 'current');
41
+ let closing = false;
42
+ const shutdown = async () => {
43
+ if (closing) {
44
+ return;
45
+ }
46
+ closing = true;
47
+ const closed = await daemon.close();
48
+ // Never silent: a dispatch whose `storageState.json` did not persist is
49
+ // silent data loss for S4's login profile, and the daemon's stderr is the
50
+ // only channel that reaches `daemon.log` (R1).
51
+ for (const failure of closed.stateWriteFailures) {
52
+ process.stderr.write(`peaks web daemon: could not persist storageState for dispatch ${failure.dispatchId}: ${failure.reason}\n`);
53
+ }
54
+ process.exit(0);
55
+ };
56
+ process.on('SIGTERM', () => {
57
+ void shutdown();
58
+ });
59
+ process.on('SIGINT', () => {
60
+ void shutdown();
61
+ });
62
+ }
63
+ main().catch((error) => {
64
+ fatal(`startup failed: ${getErrorMessage(error)}`);
65
+ });
@@ -0,0 +1,42 @@
1
+ import { type WebDaemonInfo } from './web-protocol.js';
2
+ /** The token's directory: readable only by its owner, same reasoning as above. */
3
+ export declare const DAEMON_DIR_MODE = 448;
4
+ export declare function writeDaemonInfo(projectRoot: string, sessionId: string, info: WebDaemonInfo): void;
5
+ /**
6
+ * Read + validate `daemon.json`. `null` when absent, malformed, a stale
7
+ * protocol, or — R6 — when the file claims to describe a DIFFERENT
8
+ * `(projectRoot, sessionId)` than the caller's.
9
+ *
10
+ * The ownership comparison belongs here rather than in `parseDaemonInfo`,
11
+ * because only the caller knows what the record is supposed to describe. A
12
+ * planted `daemon.json` naming someone else's session would otherwise redirect
13
+ * every op (page URL, selectors, bearer token) to a port of the attacker's
14
+ * choosing, and — via `stopDaemon` — aim `process.kill` at an unrelated pid.
15
+ */
16
+ export declare function readDaemonInfo(projectRoot: string, sessionId: string): WebDaemonInfo | null;
17
+ /** Remove `daemon.json`. Idempotent: a missing file is not an error. */
18
+ export declare function removeDaemonInfo(projectRoot: string, sessionId: string): void;
19
+ /**
20
+ * `process.kill(pid, 0)` is the cross-platform existence probe: it throws
21
+ * `ESRCH` when no such process exists and `EPERM` when one exists but belongs
22
+ * to another user and so may not be signalled. Only `ESRCH` means dead — an
23
+ * alive-but-unsignalable pid must not be reported as dead, or `stopDaemon`
24
+ * skips the kill and `acquireSpawnLock` reclaims a lock whose owner still
25
+ * holds it.
26
+ */
27
+ export declare function isProcessAlive(pid: number): boolean;
28
+ /**
29
+ * Take the cold-start lock (O_EXCL create). Returns `false` when another
30
+ * process holds a live lock, and reclaims a STALE one — owner pid dead, or
31
+ * older than `SPAWN_LOCK_STALE_MS` — before retrying once (tech-doc §1.4).
32
+ */
33
+ export declare function acquireSpawnLock(projectRoot: string, sessionId: string): boolean;
34
+ /** Release the spawn lock, but only when this process is its owner. */
35
+ export declare function releaseSpawnLock(projectRoot: string, sessionId: string): void;
36
+ /**
37
+ * The daemon instances for a session. There is one daemon per
38
+ * `(projectRoot, sessionId)` (design §10.2), so this list holds at most one
39
+ * entry; it is an array because the S2 status report folds it together with the
40
+ * lock and `/health` classification of each instance.
41
+ */
42
+ export declare function listSessionDaemons(projectRoot: string, sessionId: string): WebDaemonInfo[];