peaks-loop 4.0.36 → 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 (88) hide show
  1. package/CHANGELOG.md +24 -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.js +21 -5
  10. package/dist/cli/commands/hooks-commands.js +10 -1
  11. package/dist/cli/commands/job-commands.js +107 -25
  12. package/dist/cli/commands/scan-commands.js +1 -1
  13. package/dist/cli/commands/web-commands.d.ts +28 -0
  14. package/dist/cli/commands/web-commands.js +327 -0
  15. package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
  16. package/dist/cli/commands/web-lifecycle-commands.js +321 -0
  17. package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
  18. package/dist/services/best-practice/scan-orchestrator.js +14 -5
  19. package/dist/services/code/orchestrator-can-do.js +27 -4
  20. package/dist/services/context/context-audit-hint.d.ts +79 -0
  21. package/dist/services/context/context-audit-hint.js +150 -0
  22. package/dist/services/hooks/auto-compact-hook-install.js +10 -1
  23. package/dist/services/hooks/write-gate.js +88 -0
  24. package/dist/services/lint/detect-eslint.d.ts +2 -0
  25. package/dist/services/lint/detect-eslint.js +23 -9
  26. package/dist/services/lint/npx-resolver.d.ts +6 -0
  27. package/dist/services/lint/npx-resolver.js +38 -14
  28. package/dist/services/release/version-precheck-service.js +9 -2
  29. package/dist/services/scan/file-size-scan.d.ts +29 -0
  30. package/dist/services/scan/file-size-scan.js +63 -0
  31. package/dist/services/session/caller-binding-service.d.ts +24 -0
  32. package/dist/services/session/caller-binding-service.js +34 -0
  33. package/dist/services/session/getSessionDir.js +15 -10
  34. package/dist/services/skills/hooks-codegate-superpowers.d.ts +33 -0
  35. package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
  36. package/dist/services/skills/hooks-settings-service.d.ts +10 -0
  37. package/dist/services/skills/hooks-settings-service.js +152 -61
  38. package/dist/services/slice/slice-check-service.d.ts +14 -0
  39. package/dist/services/slice/slice-check-service.js +110 -50
  40. package/dist/services/slice/slice-check-types.d.ts +12 -7
  41. package/dist/services/slice/slice-check-types.js +8 -3
  42. package/dist/services/slice/slice-decompose-runners.js +24 -21
  43. package/dist/services/sop/sop-check-service.js +12 -1
  44. package/dist/services/web/bounded-output.d.ts +34 -0
  45. package/dist/services/web/bounded-output.js +68 -0
  46. package/dist/services/web/browser-acquire.d.ts +14 -0
  47. package/dist/services/web/browser-acquire.js +84 -0
  48. package/dist/services/web/browser-session-manager.d.ts +111 -0
  49. package/dist/services/web/browser-session-manager.js +413 -0
  50. package/dist/services/web/daemon-entry.d.ts +1 -0
  51. package/dist/services/web/daemon-entry.js +65 -0
  52. package/dist/services/web/daemon-registry.d.ts +42 -0
  53. package/dist/services/web/daemon-registry.js +164 -0
  54. package/dist/services/web/daemon-supervisor.d.ts +144 -0
  55. package/dist/services/web/daemon-supervisor.js +455 -0
  56. package/dist/services/web/playwright-loader.d.ts +89 -0
  57. package/dist/services/web/playwright-loader.js +253 -0
  58. package/dist/services/web/snapshot-pruner.d.ts +48 -0
  59. package/dist/services/web/snapshot-pruner.js +241 -0
  60. package/dist/services/web/untrusted-envelope.d.ts +27 -0
  61. package/dist/services/web/untrusted-envelope.js +44 -0
  62. package/dist/services/web/web-artifact-paths.d.ts +79 -0
  63. package/dist/services/web/web-artifact-paths.js +163 -0
  64. package/dist/services/web/web-client.d.ts +19 -0
  65. package/dist/services/web/web-client.js +55 -0
  66. package/dist/services/web/web-daemon-service.d.ts +38 -0
  67. package/dist/services/web/web-daemon-service.js +416 -0
  68. package/dist/services/web/web-fallback.d.ts +70 -0
  69. package/dist/services/web/web-fallback.js +121 -0
  70. package/dist/services/web/web-install-service.d.ts +91 -0
  71. package/dist/services/web/web-install-service.js +346 -0
  72. package/dist/services/web/web-login-profile.d.ts +89 -0
  73. package/dist/services/web/web-login-profile.js +612 -0
  74. package/dist/services/web/web-login-staging.d.ts +27 -0
  75. package/dist/services/web/web-login-staging.js +173 -0
  76. package/dist/services/web/web-protocol.d.ts +58 -0
  77. package/dist/services/web/web-protocol.js +58 -0
  78. package/dist/services/web/web-status-report.d.ts +33 -0
  79. package/dist/services/web/web-status-report.js +47 -0
  80. package/dist/services/workspace/claude-settings-template.d.ts +41 -5
  81. package/dist/services/workspace/claude-settings-template.js +116 -64
  82. package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
  83. package/dist/services/workspace/workspace-service.js +33 -0
  84. package/package.json +5 -5
  85. package/scripts/copy-templates.mjs +12 -0
  86. package/scripts/sync-version.mjs +20 -0
  87. package/skills/peaks-code/SKILL.md +10 -0
  88. package/skills/peaks-code/references/browser-workflow.md +10 -1
@@ -0,0 +1,455 @@
1
+ /**
2
+ * CLI-side daemon lifecycle: reuse, cold start, stop (slice S1, file 8).
3
+ *
4
+ * The CLI process is short-lived; the daemon is not. `ensureDaemon` is the
5
+ * whole cold-start/warm path from tech-doc §1.4. The lock exists because Q10
6
+ * lets the orchestrator and a sub-agent call `peaks web` concurrently in the
7
+ * same session — without it, two daemons would race into the same session key
8
+ * and Q8 ("one browser process per session") would be violated.
9
+ */
10
+ import { spawn } from 'node:child_process';
11
+ import { closeSync, existsSync, mkdirSync, openSync, statSync } from 'node:fs';
12
+ import { delimiter, dirname, extname, join, resolve } from 'node:path';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import { getErrorMessage } from 'peaks-loop-shared/result';
15
+ import { acquireSpawnLock, DAEMON_DIR_MODE, isProcessAlive, readDaemonInfo, releaseSpawnLock, removeDaemonInfo } from './daemon-registry.js';
16
+ import { resolvePlaywrightModule } from './playwright-loader.js';
17
+ import { webDir, webLogPath } from './web-artifact-paths.js';
18
+ import { WebDaemonClient } from './web-client.js';
19
+ const READY_TIMEOUT_MS = 20_000;
20
+ const READY_POLL_MS = 250;
21
+ /**
22
+ * `daemon.log` is append-only and nothing reaps it, so a cold start truncates
23
+ * it once it passes this. One boot line per start plus Playwright's own output
24
+ * means the cap is reached in months of normal use and never in a test run —
25
+ * the point is that the file has a bound at all.
26
+ */
27
+ const MAX_LOG_BYTES = 1_048_576;
28
+ /**
29
+ * How long the daemon gets to answer the stop op / leave the process table.
30
+ *
31
+ * `STOP_EXIT_TIMEOUT_MS` must outlast the daemon's own bounded teardown, or the
32
+ * fallback `SIGTERM` (= `TerminateProcess` on Windows) lands mid-teardown and
33
+ * orphans the browser it was closing (AC6). That teardown is
34
+ * `TEARDOWN_BUDGET_MS + 4 x TEARDOWN_STEP_TIMEOUT_MS = 11 000 ms` after the S3
35
+ * repair raised the per-step budget; 15 s is that with margin. The wait POLLS,
36
+ * so a normal stop returns as soon as the process is gone and pays nothing.
37
+ */
38
+ const STOP_REQUEST_TIMEOUT_MS = 5_000;
39
+ const STOP_EXIT_TIMEOUT_MS = 15_000;
40
+ const STOP_POLL_MS = 100;
41
+ /**
42
+ * This module's own directory — `<root>/src/services/web` or
43
+ * `<root>/dist/services/web`.
44
+ *
45
+ * NOT `process.argv[1]`. The anchor has to be the running module, because
46
+ * `argv[1]` is a different file in each of the three ways this code is entered:
47
+ * `bin/peaks.js` for the packaged bin, `src/cli/index.ts` under `tsx`, and
48
+ * `dist/cli/index.js` when the entry is invoked directly (R8's root cause).
49
+ * The module's own location is the one fact that is always true.
50
+ */
51
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
52
+ /** `<root>/src` or `<root>/dist` — the tree this code was loaded from. */
53
+ const TREE_DIR = resolve(MODULE_DIR, '..', '..');
54
+ /** Compiled trees run `.js` under `node`; the source tree is TypeScript. */
55
+ const SOURCE_TREE = extname(fileURLToPath(import.meta.url)) === '.ts';
56
+ /**
57
+ * Absolute path of the daemon entry. The NAME is fixed by the tree we are
58
+ * running from, so the two supported modes each get a target that exists:
59
+ * `dist/services/web/daemon-entry.js` after `pnpm build`, and
60
+ * `src/services/web/daemon-entry.ts` for `pnpm dev` / the test suite.
61
+ */
62
+ export function daemonEntryPath() {
63
+ return join(MODULE_DIR, SOURCE_TREE ? 'daemon-entry.ts' : 'daemon-entry.js');
64
+ }
65
+ /**
66
+ * Absolute path of the peaks CLI entry — the file that owns
67
+ * `sub-agent shutdown register`.
68
+ *
69
+ * Explicit, never `process.argv[1]`: the caller of `registerWithParent` is the
70
+ * DAEMON, whose `argv[1]` is the daemon entry, so spawning it with CLI
71
+ * arguments boots a second daemon instead of registering anything (R8).
72
+ */
73
+ export function cliEntryPath() {
74
+ return join(TREE_DIR, 'cli', SOURCE_TREE ? 'index.ts' : 'index.js');
75
+ }
76
+ /**
77
+ * Arguments that make `node` run `entry`.
78
+ *
79
+ * A `.ts` entry needs the TypeScript loader, and it is attached with
80
+ * `--require preflight.cjs --import loader.mjs` — the exact flags `tsx` passes
81
+ * to its own child — rather than by running the `tsx` CLI.
82
+ *
83
+ * That distinction is not cosmetic. `windowsHide` applies to the process we
84
+ * spawn and to nothing it spawns in turn: launching `node tsx-cli.mjs entry.ts`
85
+ * makes tsx spawn a GRANDCHILD, and the grandchild has no `windowsHide` of ours
86
+ * — so on Windows every dev-mode `peaks web` cold start popped a console window
87
+ * the user could not close. Invoking the loader directly keeps it one process,
88
+ * so the flag we already pass covers everything.
89
+ *
90
+ * Exported so the race test's caller process is launched through the same
91
+ * shape as the daemon: two copies of these flags would drift.
92
+ *
93
+ * Both callers prepend `process.execPath` themselves, so this returns ONLY the
94
+ * interpreter flags and the entry.
95
+ */
96
+ export function interpreterArgs(entry) {
97
+ if (!entry.endsWith('.ts')) {
98
+ return [entry];
99
+ }
100
+ const tsxDist = resolve(TREE_DIR, '..', 'node_modules', 'tsx', 'dist');
101
+ const preflight = join(tsxDist, 'preflight.cjs');
102
+ const loader = join(tsxDist, 'loader.mjs');
103
+ if (!existsSync(preflight) || !existsSync(loader)) {
104
+ throw new Error(`WEB_DAEMON_TSX_MISSING: ${entry} is TypeScript but the tsx loader is not installed at ${tsxDist}. ` +
105
+ 'Run `pnpm install`, or `pnpm build` to use the compiled daemon entry.');
106
+ }
107
+ return [
108
+ '--require',
109
+ preflight,
110
+ ...(supportsImportFlag() ? ['--import'] : ['--loader']),
111
+ pathToFileURL(loader).href,
112
+ entry
113
+ ];
114
+ }
115
+ /**
116
+ * Whether this Node spells the ESM loader flag `--import`.
117
+ *
118
+ * It arrived in 20.6.0 (backported to 18.19.0); below that the spelling is
119
+ * `--loader`, and passing `--import` is a bad option that kills the daemon at
120
+ * startup — inside a `READY_TIMEOUT_MS = 20 s` wait whose only diagnosis is a
121
+ * line in `daemon.log`. `package.json` declares `engines.node >= 20.0.0`, which
122
+ * includes 20.0–20.5, so the flag cannot be unconditional. `tsx` gates its own
123
+ * child the same way and this mirrors it.
124
+ */
125
+ export function supportsImportFlag(nodeVersion = process.versions.node) {
126
+ const [major = 0, minor = 0] = nodeVersion.split('.').map(Number);
127
+ return major >= 21 || (major === 20 && minor >= 6) || (major === 18 && minor >= 19);
128
+ }
129
+ /**
130
+ * The exact command `spawnDaemon` hands to `spawn`, resolved without launching
131
+ * anything so a test can assert its shape.
132
+ *
133
+ * `process.execPath` directly — never `npx`, and therefore never the
134
+ * `cmd.exe /d /s /c node …` that npm's own `run-script` puts between us and the
135
+ * daemon (`@npmcli/run-script/lib/make-spawn-args.js` spawns with `shell: true`
136
+ * and no `windowsHide`). That layer was three processes per cold start instead
137
+ * of one, it re-parsed our `--require` / `--import` paths through a command
138
+ * string, it cost 2.8–7.1 s of the ~3 s cold start, and the two intermediate
139
+ * processes ignored `windowsHide` — the console-window incident, one layer down.
140
+ */
141
+ export function daemonSpawnCommand() {
142
+ return { command: process.execPath, args: interpreterArgs(daemonEntryPath()) };
143
+ }
144
+ /**
145
+ * Spawn the detached daemon. Both stdio pipes are redirected to
146
+ * `web/daemon/daemon.log` — a daemon that inherited our stdout, or wrote to
147
+ * `cwd`, would be the sneakiest way to break AC1 (tech-doc §7.2 rule 6).
148
+ */
149
+ export function spawnDaemon(projectRoot, sessionId) {
150
+ const logPath = webLogPath(projectRoot, sessionId);
151
+ mkdirSync(dirname(logPath), { recursive: true, mode: DAEMON_DIR_MODE });
152
+ truncateLogIfOversized(logPath);
153
+ const logFd = openSync(logPath, 'a');
154
+ try {
155
+ const invocation = daemonSpawnCommand();
156
+ const child = spawn(invocation.command, invocation.args, {
157
+ cwd: projectRoot,
158
+ env: daemonEnv(projectRoot, sessionId),
159
+ stdio: ['ignore', logFd, logFd],
160
+ detached: true,
161
+ // `detached` alone would give this daemon its own VISIBLE console window
162
+ // on Windows — one the user cannot close, per spawn, for a process whose
163
+ // whole purpose is to be invisible.
164
+ windowsHide: true
165
+ });
166
+ // A `ChildProcess` that emits `error` with no listener is an uncaught
167
+ // exception: the CLI would die with a raw ENOENT stack and exit 7 instead of
168
+ // letting `ensureDaemon` report its own `WEB_DAEMON_TIMEOUT` envelope.
169
+ child.on('error', (error) => {
170
+ process.stderr.write(`peaks web: daemon spawn failed: ${getErrorMessage(error)}\n`);
171
+ });
172
+ child.unref();
173
+ return { pid: child.pid };
174
+ }
175
+ finally {
176
+ closeSync(logFd);
177
+ }
178
+ }
179
+ /**
180
+ * The daemon's environment.
181
+ *
182
+ * `npx` used to be what made `playwright` resolvable inside the daemon; the
183
+ * daemon is now a direct child, so this process resolves the pinned package
184
+ * itself and puts its `node_modules/.bin` first on the child's `PATH`. The
185
+ * daemon's own resolution no longer reads `PATH` at all (`playwright-loader.ts`
186
+ * scans only the module path and the npm exec cache — a PATH entry is an
187
+ * attacker-influenceable root), so this prepend is what keeps the bare `npx`
188
+ * that POSIX reaches for pointing at the pinned package. Unresolvable here
189
+ * means the daemon answers its first browser op with `PLAYWRIGHT_NOT_RESOLVABLE`
190
+ * rather than silently reaching for a shell.
191
+ */
192
+ function daemonEnv(projectRoot, sessionId) {
193
+ const env = {
194
+ ...process.env,
195
+ PEAKS_WEB_PROJECT_ROOT: projectRoot,
196
+ PEAKS_WEB_SESSION_ID: sessionId,
197
+ PEAKS_WEB_ARTIFACT_DIR: webDir(projectRoot, sessionId),
198
+ PEAKS_DISPATCH_ID: process.env['PEAKS_DISPATCH_ID'] ?? 'current'
199
+ };
200
+ const playwrightBin = playwrightBinDir();
201
+ if (playwrightBin !== null) {
202
+ // Windows spells it `Path`; two case-variant keys make `CreateProcess`
203
+ // resolve the search path non-deterministically, so the old key goes.
204
+ for (const key of Object.keys(env)) {
205
+ if (key.toUpperCase() === 'PATH') {
206
+ delete env[key];
207
+ }
208
+ }
209
+ env['PATH'] = `${playwrightBin}${delimiter}${process.env['PATH'] ?? ''}`;
210
+ }
211
+ return env;
212
+ }
213
+ /** `<…>/node_modules/.bin` for the resolved Playwright, or `null` when absent. */
214
+ function playwrightBinDir() {
215
+ try {
216
+ return join(dirname(dirname(resolvePlaywrightModule())), '.bin');
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ }
222
+ /** Truncate the daemon's log at cold start once it has outgrown the cap. */
223
+ function truncateLogIfOversized(logPath) {
224
+ try {
225
+ if (statSync(logPath).size > MAX_LOG_BYTES) {
226
+ closeSync(openSync(logPath, 'w'));
227
+ }
228
+ }
229
+ catch {
230
+ // No log yet.
231
+ }
232
+ }
233
+ /**
234
+ * Return a healthy daemon for this `(projectRoot, sessionId)`, spawning one only
235
+ * when the session has none. A record whose pid is GONE is replaced (a stale
236
+ * file from a SIGKILLed parent, R3); a record whose pid is ALIVE is never
237
+ * replaced, even when `/health` does not answer — see `DaemonProbe`.
238
+ *
239
+ * Cold start is **double-checked locking**: the record, its pid liveness and
240
+ * `/health` are re-probed AFTER the lock is acquired, with the same oracle the
241
+ * pre-lock check used, and the lock is held across the spawn AND the readiness
242
+ * wait. Q10 lets the orchestrator and a sub-agent call
243
+ * `peaks web` at the same moment, and `spawnDaemon` returns as soon as `spawn()`
244
+ * does, so releasing the lock there left the whole ~20 s startup window
245
+ * unprotected: both callers would see no daemon, take the lock in turn, and
246
+ * launch a browser each — two processes per session in violation of Q8, one of
247
+ * them unreachable by `peaks web stop`. With the lock held, the loser waits for
248
+ * the winner's daemon instead of spawning its own.
249
+ */
250
+ export async function ensureDaemon(projectRoot, sessionId) {
251
+ const existing = await probeDaemon(projectRoot, sessionId);
252
+ if (existing.state === 'healthy') {
253
+ return existing.info;
254
+ }
255
+ const holdsLock = acquireSpawnLock(projectRoot, sessionId);
256
+ try {
257
+ // Double check under the lock: the winner may have started a daemon while
258
+ // this caller was waiting to acquire it.
259
+ const late = await probeDaemon(projectRoot, sessionId);
260
+ if (late.state === 'healthy') {
261
+ return late.info;
262
+ }
263
+ // ONLY an absent daemon may be replaced. `unreachable` is a LIVE pid whose
264
+ // `/health` missed a 500 ms budget — a daemon blocked in `spawnSync`'s
265
+ // chromium install or in a synchronous `snap` prune, not a dead one.
266
+ // Deleting its record would make it unreachable by every verb, and spawning
267
+ // here would put two daemons (and two browsers) behind one session key, a
268
+ // direct Q8 violation. So this branch waits for the incumbent instead.
269
+ if (late.state === 'absent' && holdsLock) {
270
+ removeDaemonInfo(projectRoot, sessionId);
271
+ spawnDaemon(projectRoot, sessionId);
272
+ }
273
+ const deadline = Date.now() + READY_TIMEOUT_MS;
274
+ while (Date.now() < deadline) {
275
+ const info = await probeDaemon(projectRoot, sessionId);
276
+ if (info.state === 'healthy') {
277
+ return info.info;
278
+ }
279
+ await delay(READY_POLL_MS);
280
+ }
281
+ throw new Error(`WEB_DAEMON_TIMEOUT: no healthy peaks web daemon for session ${sessionId} within ${READY_TIMEOUT_MS} ms` +
282
+ (late.state === 'unreachable'
283
+ ? ` (pid ${String(late.info.pid)} is alive but not answering /health)`
284
+ : ''));
285
+ }
286
+ finally {
287
+ if (holdsLock) {
288
+ releaseSpawnLock(projectRoot, sessionId);
289
+ }
290
+ }
291
+ }
292
+ async function probeDaemon(projectRoot, sessionId) {
293
+ const info = readDaemonInfo(projectRoot, sessionId);
294
+ if (info === null || !isProcessAlive(info.pid)) {
295
+ return { state: 'absent' };
296
+ }
297
+ if (await new WebDaemonClient(info).health()) {
298
+ return { state: 'healthy', info };
299
+ }
300
+ return { state: 'unreachable', info };
301
+ }
302
+ /**
303
+ * Stop this session's daemon and clear its records. Scoped to
304
+ * `(projectRoot, sessionId)` by construction — another worktree's daemon is
305
+ * untouched (design §10.2).
306
+ *
307
+ * Ownership is proven before asking the process to stop (R6/R7): a planted
308
+ * `daemon.json` naming an unrelated pid must not make `peaks web stop` kill a
309
+ * process the caller does not own, and on Windows `SIGTERM` is
310
+ * `TerminateProcess` with no chance to say "no". `/health` is NOT evidence —
311
+ * it is answered before the auth check, so any unrelated local listener that
312
+ * returns 2xx for a `GET` satisfies it. The proof is an AUTHENTICATED `/op`
313
+ * whose reply reports the very identity the record claims (`isOwnDaemon`): a
314
+ * recycled pid pointing at somebody else's dev server cannot produce it.
315
+ *
316
+ * The graceful path comes FIRST, and it is not a nicety: on Windows a signal
317
+ * cannot be handled, so signalling would terminate the daemon without running
318
+ * its teardown, leaving its chromium process behind — the exact false pass
319
+ * AC6 is written against. `/op {op:'stop'}` lets the daemon close every context,
320
+ * persist storage state, close the browser and exit on its own terms; the
321
+ * signal is only the fallback for a daemon that accepts the request and then
322
+ * fails to leave.
323
+ *
324
+ * An instance that is alive but cannot be proven ours is **not** signalled and
325
+ * **not** de-recorded. Its record is the only handle on a running process:
326
+ * clearing it would leave the daemon and its chromium detached with no verb
327
+ * able to reach them, and would let the next cold start spawn a second daemon
328
+ * beside a live one (Q8). It is reported in `orphanedPids`, and a daemon that
329
+ * survives its own stop request stays recorded too, so `status` and a second
330
+ * `stop` still see it.
331
+ */
332
+ export async function stopDaemon(projectRoot, sessionId) {
333
+ const info = readDaemonInfo(projectRoot, sessionId);
334
+ const pids = [];
335
+ const orphanedPids = [];
336
+ if (info !== null && isProcessAlive(info.pid)) {
337
+ const client = new WebDaemonClient(info);
338
+ if (await isOwnDaemon(client, info)) {
339
+ await requestStop(client);
340
+ pids.push(info.pid);
341
+ if (!(await waitForExit(info.pid, STOP_EXIT_TIMEOUT_MS))) {
342
+ try {
343
+ process.kill(info.pid, 'SIGTERM');
344
+ }
345
+ catch {
346
+ // It exited between the probe and the signal.
347
+ }
348
+ await waitForExit(info.pid, STOP_EXIT_TIMEOUT_MS);
349
+ }
350
+ }
351
+ else {
352
+ orphanedPids.push(info.pid);
353
+ }
354
+ }
355
+ // The record survives whenever something live is still behind it.
356
+ if (info === null || !isProcessAlive(info.pid)) {
357
+ removeDaemonInfo(projectRoot, sessionId);
358
+ releaseSpawnLock(projectRoot, sessionId);
359
+ }
360
+ return { stopped: pids.filter((pid) => !isProcessAlive(pid)).length, pids, orphanedPids };
361
+ }
362
+ /**
363
+ * True only when an authenticated daemon answers with the identity this record
364
+ * claims. `/health` cannot be used for this: it is unauthenticated by contract
365
+ * and answered before the auth check, so "something answered 2xx" is satisfied
366
+ * by any local HTTP server — which is exactly how a stale record turns a
367
+ * recycled pid into a `TerminateProcess`.
368
+ */
369
+ async function isOwnDaemon(client, info) {
370
+ try {
371
+ const response = await client.call('whoami', {}, STOP_REQUEST_TIMEOUT_MS);
372
+ const data = response.data;
373
+ return (response.ok &&
374
+ typeof data === 'object' &&
375
+ data !== null &&
376
+ data.pid === info.pid &&
377
+ data.sessionId === info.sessionId);
378
+ }
379
+ catch {
380
+ return false;
381
+ }
382
+ }
383
+ /** Ask the daemon to shut itself down. A refusal or a timeout means "try the signal". */
384
+ async function requestStop(client) {
385
+ try {
386
+ await client.call('stop', {}, STOP_REQUEST_TIMEOUT_MS);
387
+ }
388
+ catch {
389
+ // Transport-level failure only: the daemon is gone, or its shutdown raced
390
+ // the response. Either way the exit wait decides what actually happened.
391
+ }
392
+ }
393
+ /** True once `pid` has left the process table, false at the deadline. */
394
+ async function waitForExit(pid, timeoutMs) {
395
+ const deadline = Date.now() + timeoutMs;
396
+ while (Date.now() < deadline) {
397
+ if (!isProcessAlive(pid)) {
398
+ return true;
399
+ }
400
+ await delay(STOP_POLL_MS);
401
+ }
402
+ return !isProcessAlive(pid);
403
+ }
404
+ /**
405
+ * Best-effort registration with the existing sub-agent shutdown registry
406
+ * (Q7 / tech-doc §7.3).
407
+ *
408
+ * The entry point is `cliEntryPath()`, not `process.argv[1]`: this is called by
409
+ * the daemon, whose `argv[1]` is the daemon entry, so using it spawned a second
410
+ * daemon-entry with CLI arguments instead of registering anything (R8).
411
+ *
412
+ * Registration failing is non-fatal — the caller keeps running — but it must
413
+ * not be silent: the daemon's stderr is `web/daemon/daemon.log`
414
+ * (see `spawnDaemon`), so a spawn failure is written there. A detached child
415
+ * whose `error` event has no listener would otherwise crash the daemon.
416
+ */
417
+ export function registerWithParent(daemonPid, dispatchId) {
418
+ const report = (reason) => {
419
+ process.stderr.write(`peaks web: parent shutdown registration failed: ${reason}\n`);
420
+ };
421
+ let entry;
422
+ try {
423
+ entry = cliEntryPath();
424
+ }
425
+ catch (error) {
426
+ report(getErrorMessage(error));
427
+ return;
428
+ }
429
+ try {
430
+ const child = spawn(process.execPath, [
431
+ ...interpreterArgs(entry),
432
+ 'sub-agent',
433
+ 'shutdown',
434
+ 'register',
435
+ '--pid',
436
+ String(daemonPid),
437
+ '--name',
438
+ 'peaks-web-daemon',
439
+ '--dispatch-id',
440
+ dispatchId
441
+ ], { stdio: 'ignore', detached: true, windowsHide: true });
442
+ child.on('error', (error) => {
443
+ report(getErrorMessage(error));
444
+ });
445
+ child.unref();
446
+ }
447
+ catch (error) {
448
+ report(getErrorMessage(error));
449
+ }
450
+ }
451
+ function delay(ms) {
452
+ return new Promise((resolveDelay) => {
453
+ setTimeout(resolveDelay, ms);
454
+ });
455
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Exact pin, no caret (tech-doc §3.2). AC2 is a BYTE-COUNT contract and the
3
+ * aria engine moves with the version, so upgrading is a deliberate,
4
+ * re-measured change — never a floating range.
5
+ */
6
+ export declare const PLAYWRIGHT_VERSION_PIN = "1.63.0";
7
+ /**
8
+ * The structural slice of the Playwright API this feature actually calls.
9
+ * Typed locally because the package is not a dependency and therefore has no
10
+ * importable type declarations.
11
+ */
12
+ export interface PwLocator {
13
+ ariaSnapshotJSON?(options?: {
14
+ boxes?: boolean;
15
+ depth?: number;
16
+ mode?: 'ai' | 'default';
17
+ timeout?: number;
18
+ }): Promise<unknown>;
19
+ click(): Promise<void>;
20
+ innerText(): Promise<string>;
21
+ screenshot(options?: {
22
+ path?: string;
23
+ type?: 'png' | 'jpeg';
24
+ }): Promise<Buffer>;
25
+ }
26
+ export interface PwPage {
27
+ goto(url: string, options?: {
28
+ waitUntil?: string;
29
+ timeout?: number;
30
+ }): Promise<unknown>;
31
+ title(): Promise<string>;
32
+ url(): string;
33
+ locator(selector: string): PwLocator;
34
+ screenshot(options?: {
35
+ path?: string;
36
+ type?: 'png' | 'jpeg';
37
+ }): Promise<Buffer>;
38
+ evaluate<T>(expression: string): Promise<T>;
39
+ }
40
+ export interface PwContext {
41
+ newPage(): Promise<PwPage>;
42
+ addInitScript(script: string): Promise<void>;
43
+ storageState(options?: {
44
+ path?: string;
45
+ }): Promise<unknown>;
46
+ close(): Promise<void>;
47
+ }
48
+ export interface PwBrowser {
49
+ newContext(options?: Record<string, unknown>): Promise<PwContext>;
50
+ version(): string;
51
+ /**
52
+ * `disconnected` is the only signal that the user CLOSED the headed window
53
+ * (S4 repair), which is what completes a login: once the browser is gone a
54
+ * non-persistent context cannot be read, so the wait has to end here.
55
+ *
56
+ * OPTIONAL: the doubles that exercise the other verbs have no disconnect to
57
+ * report, and demanding the method made three of them type-broken. A browser
58
+ * that cannot report the event is treated as never disconnecting (S4 repair,
59
+ * code review F1).
60
+ */
61
+ on?(event: 'disconnected', listener: () => void): void;
62
+ /**
63
+ * Whether the browser is still connected. Real Playwright exposes it; the
64
+ * login checks it ONCE before the wait starts, so a browser that is already
65
+ * gone fails fast instead of running out the full ten-minute bound — the
66
+ * `disconnected` event is not replayed for it (S4 repair, code review F3).
67
+ */
68
+ isConnected?(): boolean;
69
+ close(): Promise<void>;
70
+ }
71
+ export interface PlaywrightModule {
72
+ chromium: {
73
+ launch(options?: Record<string, unknown>): Promise<PwBrowser>;
74
+ executablePath(): string;
75
+ };
76
+ }
77
+ /**
78
+ * Absolute path of the `playwright` main entry.
79
+ *
80
+ * 1. the peaks install's own `node_modules` — the case where someone really did
81
+ * `npm install playwright`;
82
+ * 2. the per-user npm exec cache, where acquisition put it (see the module
83
+ * docstring) and where it stays afterwards, with no npx and no shell.
84
+ */
85
+ export declare function resolvePlaywrightModule(): string;
86
+ /** Import the resolved Playwright module. Throws `PLAYWRIGHT_NOT_RESOLVABLE` if it is absent. */
87
+ export declare function loadPlaywright(): Promise<PlaywrightModule>;
88
+ /** Installed Playwright package version, or `null` when it cannot be resolved. */
89
+ export declare function playwrightVersion(): Promise<string | null>;