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.
- package/CHANGELOG.md +24 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/bin/peaks.js +71 -1
- package/dist/cli/cli-helpers.js +7 -0
- package/dist/cli/commands/_register.js +2 -0
- package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
- package/dist/cli/commands/best-practice-scan-command.js +67 -9
- package/dist/cli/commands/code-runtime-commands.js +21 -5
- package/dist/cli/commands/hooks-commands.js +10 -1
- package/dist/cli/commands/job-commands.js +107 -25
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/web-commands.d.ts +28 -0
- package/dist/cli/commands/web-commands.js +327 -0
- package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
- package/dist/cli/commands/web-lifecycle-commands.js +321 -0
- package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
- package/dist/services/best-practice/scan-orchestrator.js +14 -5
- package/dist/services/code/orchestrator-can-do.js +27 -4
- package/dist/services/context/context-audit-hint.d.ts +79 -0
- package/dist/services/context/context-audit-hint.js +150 -0
- package/dist/services/hooks/auto-compact-hook-install.js +10 -1
- package/dist/services/hooks/write-gate.js +88 -0
- package/dist/services/lint/detect-eslint.d.ts +2 -0
- package/dist/services/lint/detect-eslint.js +23 -9
- package/dist/services/lint/npx-resolver.d.ts +6 -0
- package/dist/services/lint/npx-resolver.js +38 -14
- package/dist/services/release/version-precheck-service.js +9 -2
- package/dist/services/scan/file-size-scan.d.ts +29 -0
- package/dist/services/scan/file-size-scan.js +63 -0
- package/dist/services/session/caller-binding-service.d.ts +24 -0
- package/dist/services/session/caller-binding-service.js +34 -0
- package/dist/services/session/getSessionDir.js +15 -10
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +33 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
- package/dist/services/skills/hooks-settings-service.d.ts +10 -0
- package/dist/services/skills/hooks-settings-service.js +152 -61
- package/dist/services/slice/slice-check-service.d.ts +14 -0
- package/dist/services/slice/slice-check-service.js +110 -50
- package/dist/services/slice/slice-check-types.d.ts +12 -7
- package/dist/services/slice/slice-check-types.js +8 -3
- package/dist/services/slice/slice-decompose-runners.js +24 -21
- package/dist/services/sop/sop-check-service.js +12 -1
- package/dist/services/web/bounded-output.d.ts +34 -0
- package/dist/services/web/bounded-output.js +68 -0
- package/dist/services/web/browser-acquire.d.ts +14 -0
- package/dist/services/web/browser-acquire.js +84 -0
- package/dist/services/web/browser-session-manager.d.ts +111 -0
- package/dist/services/web/browser-session-manager.js +413 -0
- package/dist/services/web/daemon-entry.d.ts +1 -0
- package/dist/services/web/daemon-entry.js +65 -0
- package/dist/services/web/daemon-registry.d.ts +42 -0
- package/dist/services/web/daemon-registry.js +164 -0
- package/dist/services/web/daemon-supervisor.d.ts +144 -0
- package/dist/services/web/daemon-supervisor.js +455 -0
- package/dist/services/web/playwright-loader.d.ts +89 -0
- package/dist/services/web/playwright-loader.js +253 -0
- package/dist/services/web/snapshot-pruner.d.ts +48 -0
- package/dist/services/web/snapshot-pruner.js +241 -0
- package/dist/services/web/untrusted-envelope.d.ts +27 -0
- package/dist/services/web/untrusted-envelope.js +44 -0
- package/dist/services/web/web-artifact-paths.d.ts +79 -0
- package/dist/services/web/web-artifact-paths.js +163 -0
- package/dist/services/web/web-client.d.ts +19 -0
- package/dist/services/web/web-client.js +55 -0
- package/dist/services/web/web-daemon-service.d.ts +38 -0
- package/dist/services/web/web-daemon-service.js +416 -0
- package/dist/services/web/web-fallback.d.ts +70 -0
- package/dist/services/web/web-fallback.js +121 -0
- package/dist/services/web/web-install-service.d.ts +91 -0
- package/dist/services/web/web-install-service.js +346 -0
- package/dist/services/web/web-login-profile.d.ts +89 -0
- package/dist/services/web/web-login-profile.js +612 -0
- package/dist/services/web/web-login-staging.d.ts +27 -0
- package/dist/services/web/web-login-staging.js +173 -0
- package/dist/services/web/web-protocol.d.ts +58 -0
- package/dist/services/web/web-protocol.js +58 -0
- package/dist/services/web/web-status-report.d.ts +33 -0
- package/dist/services/web/web-status-report.js +47 -0
- package/dist/services/workspace/claude-settings-template.d.ts +41 -5
- package/dist/services/workspace/claude-settings-template.js +116 -64
- package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
- package/dist/services/workspace/workspace-service.js +33 -0
- package/package.json +5 -5
- package/scripts/copy-templates.mjs +12 -0
- package/scripts/sync-version.mjs +20 -0
- package/skills/peaks-code/SKILL.md +10 -0
- 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>;
|