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.
- package/CHANGELOG.md +40 -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.d.ts +5 -2
- package/dist/cli/commands/code-runtime-commands.js +78 -7
- package/dist/cli/commands/core/doctor-command.d.ts +8 -0
- package/dist/cli/commands/core/doctor-command.js +44 -2
- package/dist/cli/commands/core/memory-command.js +5 -1
- package/dist/cli/commands/dispatch-commands.js +15 -3
- package/dist/cli/commands/dispatch-from-dag.js +17 -0
- package/dist/cli/commands/hooks-commands.js +10 -1
- package/dist/cli/commands/job-commands.js +107 -25
- package/dist/cli/commands/memory-commands.d.ts +24 -0
- package/dist/cli/commands/memory-commands.js +77 -10
- package/dist/cli/commands/request-commands.d.ts +8 -0
- package/dist/cli/commands/request-commands.js +23 -2
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/sub-agent-commands.js +2 -0
- package/dist/cli/commands/wave-plan-commands.d.ts +24 -0
- package/dist/cli/commands/wave-plan-commands.js +93 -0
- 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/build-dispatch-system-prompt.d.ts +66 -9
- package/dist/services/context/build-dispatch-system-prompt.js +132 -17
- package/dist/services/context/context-audit-hint.d.ts +79 -0
- package/dist/services/context/context-audit-hint.js +150 -0
- package/dist/services/context/context-audit.d.ts +100 -0
- package/dist/services/context/context-audit.js +322 -0
- package/dist/services/context/summary-view.d.ts +54 -0
- package/dist/services/context/summary-view.js +114 -0
- package/dist/services/dispatch/file-overlap-wave-planner.d.ts +70 -0
- package/dist/services/dispatch/file-overlap-wave-planner.js +119 -0
- package/dist/services/dispatch/session-capsule.d.ts +23 -0
- package/dist/services/dispatch/session-capsule.js +56 -0
- package/dist/services/dispatch/slice-dag.d.ts +9 -0
- package/dist/services/dispatch/slice-dag.js +9 -1
- package/dist/services/dispatch/test-tool-detection.d.ts +12 -1
- package/dist/services/dispatch/test-tool-detection.js +14 -13
- package/dist/services/hooks/auto-compact-hook-install.js +10 -1
- package/dist/services/hooks/write-gate.js +88 -0
- package/dist/services/ide/adapters/claude-code-adapter.d.ts +10 -0
- package/dist/services/ide/adapters/claude-code-adapter.js +20 -1
- package/dist/services/ide/ide-types.d.ts +15 -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/memory/project-memory-service/parsers/frontmatter.d.ts +5 -0
- package/dist/services/memory/project-memory-service/parsers/frontmatter.js +55 -5
- 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/bee/peaks-qa/SKILL.md +2 -0
- package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +12 -0
- package/skills/bee/peaks-rd/SKILL.md +2 -0
- package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +14 -0
- package/skills/bee/peaks-txt/SKILL.md +2 -0
- package/skills/bee/peaks-ui/SKILL.md +2 -0
- package/skills/peaks-code/SKILL.md +18 -0
- package/skills/peaks-code/references/browser-workflow.md +10 -1
- package/skills/peaks-code/references/context-governance.md +29 -0
- package/skills/peaks-doctor/SKILL.md +2 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { registerDispatchCommand } from './dispatch-commands.js';
|
|
2
2
|
import { registerHeartbeatCommand } from './heartbeat-commands.js';
|
|
3
3
|
import { registerShareCommand, registerSharedReadCommand, registerAwaitCommand, registerFinalizeCommand } from './share-commands.js';
|
|
4
|
+
import { registerWavePlanCommand } from './wave-plan-commands.js';
|
|
4
5
|
// Re-export `validateRole` for backward compat — the integration test
|
|
5
6
|
// suite and any external callers still import it from this entry file.
|
|
6
7
|
// The canonical implementation now lives in `sub-agent-shared.ts`.
|
|
@@ -17,4 +18,5 @@ export function registerSubAgentCommands(program, io) {
|
|
|
17
18
|
registerSharedReadCommand(subAgent, io);
|
|
18
19
|
registerAwaitCommand(subAgent, io);
|
|
19
20
|
registerFinalizeCommand(subAgent, io); // D21
|
|
21
|
+
registerWavePlanCommand(subAgent, io); // §3 file-overlap wave planner
|
|
20
22
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks sub-agent wave-plan` — slice 2026-09-10-dispatch-token-and-swarm §3.
|
|
3
|
+
*
|
|
4
|
+
* Nested under the existing `sub-agent` verb (no new top-level verb, per the
|
|
5
|
+
* project-level rule that users never learn a new CLI surface; the LLM runs
|
|
6
|
+
* this on their behalf).
|
|
7
|
+
*
|
|
8
|
+
* Input: slice descriptors `{ slices: [{ id, files: [...] }] }` from a JSON
|
|
9
|
+
* file (`--slices <file>`) or inline (`--slices-json '<json>'`).
|
|
10
|
+
*
|
|
11
|
+
* Output: a machine-readable wave plan where every wave's slices have
|
|
12
|
+
* pairwise-disjoint file sets, plus the per-slice deferral reason naming the
|
|
13
|
+
* colliding file. The orchestrator uses this to fan out a level in parallel
|
|
14
|
+
* WITHOUT serializing on a shared file — the deferred slices simply run in
|
|
15
|
+
* the next wave.
|
|
16
|
+
*/
|
|
17
|
+
import type { Command } from 'commander';
|
|
18
|
+
import { type ProgramIO } from '../cli-helpers.js';
|
|
19
|
+
export interface WavePlanOptions {
|
|
20
|
+
slices?: string;
|
|
21
|
+
slicesJson?: string;
|
|
22
|
+
json?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function registerWavePlanCommand(parent: Command, io: ProgramIO): void;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { getErrorMessage, ok, fail } from 'peaks-loop-shared/result';
|
|
3
|
+
import { addJsonOption, printResult } from '../cli-helpers.js';
|
|
4
|
+
import { planFileOverlapWaves } from '../../services/dispatch/file-overlap-wave-planner.js';
|
|
5
|
+
export function registerWavePlanCommand(parent, io) {
|
|
6
|
+
addJsonOption(parent
|
|
7
|
+
.command('wave-plan')
|
|
8
|
+
.description('§3 file-overlap-aware scheduling: read slice descriptors ' +
|
|
9
|
+
'({slices:[{id,files:[]}]}) and emit a wave plan where every wave is ' +
|
|
10
|
+
'pairwise file-disjoint. Overlapping slices are deferred to later ' +
|
|
11
|
+
'waves with the colliding file named. Machine-readable envelope; the ' +
|
|
12
|
+
'LLM runs this, users never type it.')
|
|
13
|
+
.option('--slices <file>', 'path to a JSON file: { "slices": [{ "id": "s1", "files": ["src/a.ts"] }] }')
|
|
14
|
+
.option('--slices-json <json>', 'inline JSON with the same shape as --slices')).action((options) => {
|
|
15
|
+
const asJson = options.json === true;
|
|
16
|
+
const source = typeof options.slicesJson === 'string' && options.slicesJson.length > 0
|
|
17
|
+
? { text: options.slicesJson }
|
|
18
|
+
: typeof options.slices === 'string' && options.slices.length > 0
|
|
19
|
+
? readSlicesFile(options.slices)
|
|
20
|
+
: null;
|
|
21
|
+
if (source === null) {
|
|
22
|
+
printResult(io, fail('sub-agent.wave-plan', 'MISSING_INPUT', 'pass --slices <file> or --slices-json <json>', { ok: false, waves: [] }, ['Provide slice descriptors as { "slices": [{ "id": "s1", "files": ["src/a.ts"] }] }.']), asJson);
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (source.error !== undefined) {
|
|
27
|
+
printResult(io, fail('sub-agent.wave-plan', 'INVALID_INPUT', source.error, { ok: false, waves: [] }, ['Check the JSON shape: { "slices": [{ "id": string, "files": string[] }] }.']), asJson);
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(source.text);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
printResult(io, fail('sub-agent.wave-plan', 'INVALID_JSON', `input is not valid JSON: ${getErrorMessage(err)}`, { ok: false, waves: [] }, ['Fix the JSON syntax and re-run.']), asJson);
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const descriptors = coerceDescriptors(parsed);
|
|
41
|
+
if (descriptors === null) {
|
|
42
|
+
printResult(io, fail('sub-agent.wave-plan', 'INVALID_SHAPE', 'expected { "slices": [{ "id": string, "files": string[] }] }', { ok: false, waves: [] }, ['Each entry needs a non-empty string id and an array of file paths.']), asJson);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const plan = planFileOverlapWaves(descriptors);
|
|
47
|
+
const warnings = plan.duplicateIds.length > 0
|
|
48
|
+
? [`DUPLICATE_SLICE_IDS: ${plan.duplicateIds.join(', ')} (first descriptor wins; the rest were not scheduled)`]
|
|
49
|
+
: [];
|
|
50
|
+
printResult(io, ok('sub-agent.wave-plan', {
|
|
51
|
+
envelopeVersion: '2.1.0',
|
|
52
|
+
ok: true,
|
|
53
|
+
sliceCount: plan.sliceCount,
|
|
54
|
+
waveCount: plan.waves.length,
|
|
55
|
+
waves: plan.waves,
|
|
56
|
+
duplicateIds: plan.duplicateIds,
|
|
57
|
+
maxParallelism: plan.waves.reduce((max, w) => Math.max(max, w.slices.length), 0)
|
|
58
|
+
}, warnings, [
|
|
59
|
+
plan.waves.length <= 1
|
|
60
|
+
? 'All slices are file-disjoint: dispatch them in a single wave.'
|
|
61
|
+
: `Dispatch wave 0 first, then each later wave after its predecessors finish; the deferred[] entries name the blocking file.`
|
|
62
|
+
]), asJson);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function readSlicesFile(path) {
|
|
66
|
+
try {
|
|
67
|
+
return { text: readFileSync(path, 'utf8') };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return { text: '', error: `cannot read --slices file ${path}: ${getErrorMessage(err)}` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function coerceDescriptors(parsed) {
|
|
74
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
75
|
+
return null;
|
|
76
|
+
const raw = parsed.slices;
|
|
77
|
+
if (!Array.isArray(raw))
|
|
78
|
+
return null;
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const item of raw) {
|
|
81
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item))
|
|
82
|
+
return null;
|
|
83
|
+
const id = item.id;
|
|
84
|
+
const files = item.files;
|
|
85
|
+
if (typeof id !== 'string' || id.length === 0)
|
|
86
|
+
return null;
|
|
87
|
+
if (files !== undefined && (!Array.isArray(files) || files.some((f) => typeof f !== 'string'))) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
out.push({ id, files: files ?? [] });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks web open|text|snap|click|shot|metrics` — the S1 command surface.
|
|
3
|
+
*
|
|
4
|
+
* Layer rule (tech-doc §1.1): this file owns commander wiring, option parsing,
|
|
5
|
+
* `--json`, `printResult` and `process.exitCode`. It owns ZERO path
|
|
6
|
+
* construction and ZERO Playwright calls — those live in `src/services/web/*`.
|
|
7
|
+
*
|
|
8
|
+
* The envelope is built once, in `runWebOp`, and that is also the single place
|
|
9
|
+
* `WRAPPED_OPS` is applied — one place, not six. Every page- or daemon-derived
|
|
10
|
+
* string that leaves this process (payload, diagnostic, warning) is capped and
|
|
11
|
+
* wrapped here, because this is the boundary the model actually reads.
|
|
12
|
+
*/
|
|
13
|
+
import type { Command } from 'commander';
|
|
14
|
+
import type { WebOp } from '../../services/web/web-protocol.js';
|
|
15
|
+
import { type ProgramIO } from '../cli-helpers.js';
|
|
16
|
+
export declare function registerWebCommands(program: Command, io: ProgramIO): void;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the session, ensure a daemon, invoke one op, and emit exactly one
|
|
19
|
+
* envelope. `WRAPPED_OPS` is applied here, after the byte caps the daemon
|
|
20
|
+
* already imposed, so the UNTRUSTED markers are never themselves truncated.
|
|
21
|
+
*
|
|
22
|
+
* The `PEAKS_WEB_DISABLED` gate is the FIRST thing that happens (tech-doc §5.1
|
|
23
|
+
* step 1, AC5). Before the session lookup, so a project with no binding still
|
|
24
|
+
* gets `WEB_DISABLED` rather than `NO_SESSION`; before `ensureDaemon`, so
|
|
25
|
+
* nothing is spawned and no lock is taken; and therefore before anything that
|
|
26
|
+
* could touch the browser cache.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runWebOp(io: ProgramIO, op: WebOp, args: Record<string, unknown>, asJson: boolean): Promise<void>;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { fail, getErrorMessage, ok } from 'peaks-loop-shared/result';
|
|
2
|
+
import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
|
|
3
|
+
import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
|
|
4
|
+
import { capText, MAX_TEXT_BYTES } from '../../services/web/bounded-output.js';
|
|
5
|
+
import { ensureDaemon } from '../../services/web/daemon-supervisor.js';
|
|
6
|
+
import { wrapUntrusted, WRAPPED_OPS } from '../../services/web/untrusted-envelope.js';
|
|
7
|
+
import { WebDaemonClient } from '../../services/web/web-client.js';
|
|
8
|
+
import { degradedEnvelope } from '../../services/web/web-fallback.js';
|
|
9
|
+
import { isWebDisabled } from '../../services/web/web-install-service.js';
|
|
10
|
+
import { cappedEcho, resolveProfileName } from '../../services/web/web-login-profile.js';
|
|
11
|
+
import { addJsonOption, printResult, redactSensitiveErrorMessage } from '../cli-helpers.js';
|
|
12
|
+
import { registerWebLifecycleCommands } from './web-lifecycle-commands.js';
|
|
13
|
+
/** A browser op is user-visible latency; 30 s is generous but bounded. */
|
|
14
|
+
const OP_TIMEOUT_MS = 30_000;
|
|
15
|
+
/**
|
|
16
|
+
* The option text names the read-only half out loud: a caller who browses with a
|
|
17
|
+
* profile must not assume the profile was refreshed by it.
|
|
18
|
+
*/
|
|
19
|
+
const PROFILE_OPTION_DESCRIPTION = 'navigate with a saved login profile (`peaks web login --profile <name>`): [a-z0-9._-], ' +
|
|
20
|
+
'1-64 chars, upper case folds to lower. The profile is READ-ONLY here — this run loads it ' +
|
|
21
|
+
'and never writes it back, so browser activity is not saved into it.';
|
|
22
|
+
const WEB_VERBS = [
|
|
23
|
+
{
|
|
24
|
+
name: 'open',
|
|
25
|
+
op: 'open',
|
|
26
|
+
description: 'Navigate the dispatch browser context to <url>. With --profile, the page loads that ' +
|
|
27
|
+
'saved login.',
|
|
28
|
+
argument: { name: '<url>', description: 'absolute URL to load' },
|
|
29
|
+
takesProfile: true,
|
|
30
|
+
toArgs: (positional, profile) => ({
|
|
31
|
+
url: positional[0] ?? '',
|
|
32
|
+
...(profile === undefined ? {} : { profile })
|
|
33
|
+
})
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'text',
|
|
37
|
+
op: 'text',
|
|
38
|
+
description: 'Return the visible text of the page (or of [selector]), byte-capped.',
|
|
39
|
+
argument: { name: '[selector]', description: 'CSS selector (default: body)' },
|
|
40
|
+
toArgs: (positional) => ({ selector: positional[0] })
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'snap',
|
|
44
|
+
op: 'snap',
|
|
45
|
+
description: 'Return a pruned ARIA snapshot of the page (or of [selector]), byte-capped.',
|
|
46
|
+
argument: { name: '[selector]', description: 'CSS selector (default: body)' },
|
|
47
|
+
toArgs: (positional) => ({ selector: positional[0] })
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'click',
|
|
51
|
+
op: 'click',
|
|
52
|
+
description: 'Click the element matching <selector> in the dispatch browser context.',
|
|
53
|
+
argument: { name: '<selector>', description: 'CSS selector to click' },
|
|
54
|
+
toArgs: (positional) => ({ selector: positional[0] ?? '' })
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'shot',
|
|
58
|
+
op: 'shot',
|
|
59
|
+
description: 'Screenshot the page (or [selector]) into the session web/ directory.',
|
|
60
|
+
argument: { name: '[selector]', description: 'CSS selector (default: full page)' },
|
|
61
|
+
toArgs: (positional) => ({ selector: positional[0] })
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'metrics',
|
|
65
|
+
op: 'metrics',
|
|
66
|
+
description: 'Return Core Web Vitals for the dispatch page, or why they are unavailable.',
|
|
67
|
+
argument: null,
|
|
68
|
+
toArgs: () => ({})
|
|
69
|
+
}
|
|
70
|
+
];
|
|
71
|
+
export function registerWebCommands(program, io) {
|
|
72
|
+
const web = program
|
|
73
|
+
.command('web')
|
|
74
|
+
.description('Bounded, isolated browser access driven by a pinned local Playwright. This is the primary ' +
|
|
75
|
+
'browser path; `peaks playwright` is kept as the MCP fallback. Every artifact lands under ' +
|
|
76
|
+
'.peaks/_runtime/<sessionId>/web/ — never in the project root.');
|
|
77
|
+
for (const verb of WEB_VERBS) {
|
|
78
|
+
const takesArgument = verb.argument !== null;
|
|
79
|
+
let command = web.command(verb.name).description(verb.description);
|
|
80
|
+
if (verb.argument !== null) {
|
|
81
|
+
command = command.argument(verb.argument.name, verb.argument.description);
|
|
82
|
+
}
|
|
83
|
+
if (verb.takesProfile === true) {
|
|
84
|
+
command = command.option('--profile <name>', PROFILE_OPTION_DESCRIPTION);
|
|
85
|
+
}
|
|
86
|
+
command = addJsonOption(command);
|
|
87
|
+
// Commander calls the handler as (…declaredArgs, options, command), so the
|
|
88
|
+
// options object sits at the declared-argument count — not at the end.
|
|
89
|
+
command.action(async (...actionArgs) => {
|
|
90
|
+
const options = actionArgs[takesArgument ? 1 : 0];
|
|
91
|
+
const rawArgument = actionArgs[0];
|
|
92
|
+
const positional = takesArgument
|
|
93
|
+
? [typeof rawArgument === 'string' ? rawArgument : undefined]
|
|
94
|
+
: [];
|
|
95
|
+
await runWebOp(io, verb.op, verb.toArgs(positional, options?.profile), options?.json === true);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
registerWebLifecycleCommands(web, io);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the session, ensure a daemon, invoke one op, and emit exactly one
|
|
102
|
+
* envelope. `WRAPPED_OPS` is applied here, after the byte caps the daemon
|
|
103
|
+
* already imposed, so the UNTRUSTED markers are never themselves truncated.
|
|
104
|
+
*
|
|
105
|
+
* The `PEAKS_WEB_DISABLED` gate is the FIRST thing that happens (tech-doc §5.1
|
|
106
|
+
* step 1, AC5). Before the session lookup, so a project with no binding still
|
|
107
|
+
* gets `WEB_DISABLED` rather than `NO_SESSION`; before `ensureDaemon`, so
|
|
108
|
+
* nothing is spawned and no lock is taken; and therefore before anything that
|
|
109
|
+
* could touch the browser cache.
|
|
110
|
+
*/
|
|
111
|
+
export async function runWebOp(io, op, args, asJson) {
|
|
112
|
+
const command = `peaks.web.${op}`;
|
|
113
|
+
// Declared outside the try so the CATCH reports the fold too (S4's F5/S5 rule
|
|
114
|
+
// for `login`, applied here): a run that dies after the name was resolved
|
|
115
|
+
// knows the canonical name just as well as a successful one.
|
|
116
|
+
let foldWarnings = [];
|
|
117
|
+
try {
|
|
118
|
+
if (isWebDisabled(process.env)) {
|
|
119
|
+
// The gate is statement #1, so a `--profile` has NOT been through the
|
|
120
|
+
// resolver yet and is still unbounded caller text. `degradedEnvelope`
|
|
121
|
+
// carries every string arg into the payload, so it is capped here — the
|
|
122
|
+
// same cap the `login` gate applies, for the same reason (S1's bounded
|
|
123
|
+
// output is a property of the envelope, not only of stdout).
|
|
124
|
+
const gateArgs = typeof args['profile'] === 'string'
|
|
125
|
+
? { ...args, profile: cappedEcho(args['profile']) }
|
|
126
|
+
: args;
|
|
127
|
+
printResult(io, degradedEnvelope(op, 'PEAKS_WEB_DISABLED=1', 3, gateArgs), asJson);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// A caller-supplied `--profile` is validated HERE, before anything is sent,
|
|
132
|
+
// and the daemon runs the SAME resolver again on the payload it receives (a
|
|
133
|
+
// value off the wire is not trusted). `resolveProfileName` folds to lower
|
|
134
|
+
// case, so the canonical name is what travels, and the fold is reported
|
|
135
|
+
// rather than silent — the contract `login` honours.
|
|
136
|
+
let profile;
|
|
137
|
+
if (typeof args['profile'] === 'string') {
|
|
138
|
+
const typed = args['profile'];
|
|
139
|
+
try {
|
|
140
|
+
profile = resolveProfileName(typed);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
printResult(io, fail(command, 'WEB_PROFILE_NAME_INVALID', profileRefusal(error), {}, PROFILE_NEXT_ACTIONS), asJson);
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (profile !== typed) {
|
|
148
|
+
foldWarnings = [
|
|
149
|
+
`--profile ${JSON.stringify(cappedEcho(typed))} resolved to the profile "${profile}"`
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const opArgs = profile === undefined ? args : { ...args, profile };
|
|
154
|
+
const projectRoot = resolveCanonicalProjectRoot(process.cwd());
|
|
155
|
+
const sessionId = getCurrentSessionId(projectRoot);
|
|
156
|
+
if (sessionId === null) {
|
|
157
|
+
printResult(io, withFold(fail(command, 'NO_SESSION', 'No peaks session is bound to this project root', {}, [
|
|
158
|
+
'Bind a session first (the LLM runs `peaks workspace init` on your behalf)'
|
|
159
|
+
]), foldWarnings), asJson);
|
|
160
|
+
process.exitCode = 1;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const info = await ensureDaemon(projectRoot, sessionId);
|
|
164
|
+
const response = await new WebDaemonClient(info).call(op, { ...opArgs, dispatchId: dispatchId(), projectRoot, sessionId }, OP_TIMEOUT_MS);
|
|
165
|
+
if (!response.ok || response.data === null) {
|
|
166
|
+
const code = safeDaemonCode(response.code);
|
|
167
|
+
// The daemon no longer downloads (R3), so "the browser is not installed"
|
|
168
|
+
// arrives as a refusal. It is AC5's tier-3 branch, not an opaque op
|
|
169
|
+
// failure: the caller must be handed the same envelope — MCP tool,
|
|
170
|
+
// install command, screenshot consequence — that the gate produces.
|
|
171
|
+
printResult(io, code === 'WEB_INSTALL_REQUIRED'
|
|
172
|
+
? withFold(degradedEnvelope(op, `WEB_INSTALL_REQUIRED: ${response.message ?? ''}`, 3, opArgs), foldWarnings)
|
|
173
|
+
: withFold(fail(command, code, failureMessage(op, response.message, response.nextActions), {}, []), foldWarnings), asJson);
|
|
174
|
+
process.exitCode = 1;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const wrapped = wrapPageData(op, response.data);
|
|
178
|
+
emit(io, ok(command, wrapped.data, [...foldWarnings, ...wrapDiagnostics(response.warnings)]), asJson, wrapped.human);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
printResult(io, withFold(fail(command, 'WEB_OP_FAILED', failureMessage(op, redactSensitiveErrorMessage(getErrorMessage(error)), []), {}, []), foldWarnings), asJson);
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* What a caller can do after a refused `--profile` — the same sentence `login`
|
|
187
|
+
* gives, because it is the same mistake and the same verb fixes it.
|
|
188
|
+
*/
|
|
189
|
+
const PROFILE_NEXT_ACTIONS = [
|
|
190
|
+
'Re-run with a name matching [a-z0-9._-], 1-64 chars',
|
|
191
|
+
'Or run `peaks web login --profile <name>` to create that profile'
|
|
192
|
+
];
|
|
193
|
+
/**
|
|
194
|
+
* The resolver's own message begins with the code, and `fail()` puts the code in
|
|
195
|
+
* front of the message again — strip it, so human output does not read
|
|
196
|
+
* `WEB_PROFILE_NAME_INVALID: WEB_PROFILE_NAME_INVALID: …` (the `login` verb does
|
|
197
|
+
* the same).
|
|
198
|
+
*/
|
|
199
|
+
function profileRefusal(error) {
|
|
200
|
+
return getErrorMessage(error).replace(/^WEB_PROFILE_NAME_INVALID:\s*/, '');
|
|
201
|
+
}
|
|
202
|
+
/** Prepend the fold notice to an envelope's warnings; never rewrite them away. */
|
|
203
|
+
function withFold(envelope, warnings) {
|
|
204
|
+
return warnings.length === 0 ? envelope : { ...envelope, warnings: [...warnings, ...envelope.warnings] };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* A daemon- or page-derived diagnostic is DATA, never an instruction: it is
|
|
208
|
+
* byte-capped and wrapped before it can reach `message` or `warnings` (AC4 /
|
|
209
|
+
* R4 — the envelope must cover the diagnostic channel, not only `data`).
|
|
210
|
+
*
|
|
211
|
+
* This matters because Playwright's own error messages embed the matched
|
|
212
|
+
* elements' HTML, so a page with two elements matching a selector can put
|
|
213
|
+
* arbitrary text on a channel the CLI would otherwise print verbatim to stdout.
|
|
214
|
+
*/
|
|
215
|
+
function wrapDiagnostic(raw) {
|
|
216
|
+
const capped = capText(text(raw), MAX_TEXT_BYTES).text;
|
|
217
|
+
return capped === '' ? '' : wrapUntrusted(capped);
|
|
218
|
+
}
|
|
219
|
+
/** Every daemon warning, capped and wrapped. Empty entries are dropped. */
|
|
220
|
+
function wrapDiagnostics(values) {
|
|
221
|
+
return values.map((value) => wrapDiagnostic(value)).filter((value) => value !== '');
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Our own static sentence, then the daemon's own words as a wrapped, capped
|
|
225
|
+
* block. The daemon's `nextActions` are folded into that block rather than
|
|
226
|
+
* forwarded: `printResult` prints `nextActions` to STDOUT as `next: …`, the
|
|
227
|
+
* channel the notice calls instruction, and the daemon's text is not ours to
|
|
228
|
+
* promote there.
|
|
229
|
+
*/
|
|
230
|
+
function failureMessage(op, message, nextActions) {
|
|
231
|
+
const detail = [text(message), ...nextActions.map((action) => text(action))]
|
|
232
|
+
.filter((line) => line !== '')
|
|
233
|
+
.join('\n');
|
|
234
|
+
const wrapped = wrapDiagnostic(detail);
|
|
235
|
+
return wrapped === '' ? `peaks web ${op} failed in the daemon` : `peaks web ${op} failed in the daemon\n${wrapped}`;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* A daemon-supplied `code` is printed on the envelope's first line, so only a
|
|
239
|
+
* protocol-shaped identifier is accepted; anything else falls back to ours.
|
|
240
|
+
*/
|
|
241
|
+
function safeDaemonCode(value) {
|
|
242
|
+
return typeof value === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(value) ? value : 'WEB_OP_FAILED';
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Wrap the page-controlled part of each verb's payload (AC4 / §6.2) and pick
|
|
246
|
+
* the bytes that human mode prints verbatim. `shot` is unwrapped: its path and
|
|
247
|
+
* byte count are ours, not the page's.
|
|
248
|
+
*/
|
|
249
|
+
function wrapPageData(op, raw) {
|
|
250
|
+
if (!WRAPPED_OPS.has(op)) {
|
|
251
|
+
return { data: raw, human: null };
|
|
252
|
+
}
|
|
253
|
+
switch (op) {
|
|
254
|
+
case 'open': {
|
|
255
|
+
const title = wrapUntrusted(text(raw['title']));
|
|
256
|
+
return { data: { url: wrapUntrusted(text(raw['url'])), title }, human: title };
|
|
257
|
+
}
|
|
258
|
+
case 'text': {
|
|
259
|
+
const value = wrapUntrusted(text(raw['text']));
|
|
260
|
+
return {
|
|
261
|
+
data: { text: value, truncated: raw['truncated'] === true, droppedBytes: count(raw['droppedBytes']) },
|
|
262
|
+
human: value
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
case 'snap': {
|
|
266
|
+
const snapshot = wrapUntrusted(text(raw['snapshot']));
|
|
267
|
+
return {
|
|
268
|
+
data: {
|
|
269
|
+
snapshot,
|
|
270
|
+
droppedNodes: count(raw['droppedNodes']),
|
|
271
|
+
depthCapped: raw['depthCapped'] === true,
|
|
272
|
+
nodeCapped: raw['nodeCapped'] === true,
|
|
273
|
+
truncated: raw['truncated'] === true,
|
|
274
|
+
droppedBytes: count(raw['droppedBytes'])
|
|
275
|
+
},
|
|
276
|
+
human: snapshot
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
case 'click': {
|
|
280
|
+
const result = wrapUntrusted(text(raw['result']));
|
|
281
|
+
return { data: { result }, human: result };
|
|
282
|
+
}
|
|
283
|
+
case 'metrics': {
|
|
284
|
+
// The rendered lines come from the daemon's payload, so they are capped
|
|
285
|
+
// here as well as at the producer: this is the boundary that reaches
|
|
286
|
+
// stdout, and the ceiling must hold whatever the daemon sends.
|
|
287
|
+
const metrics = wrapUntrusted(capText(renderMetrics(raw), MAX_TEXT_BYTES).text);
|
|
288
|
+
return { data: { metrics }, human: metrics };
|
|
289
|
+
}
|
|
290
|
+
default:
|
|
291
|
+
return { data: raw, human: null };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Print the envelope. Failures and `--json` go through `printResult`; a
|
|
296
|
+
* successful wrapped op in human mode prints its payload RAW, because the
|
|
297
|
+
* UNTRUSTED delimiters must stay on their own lines (AC4) and AC2 measures the
|
|
298
|
+
* byte count of exactly this stdout.
|
|
299
|
+
*/
|
|
300
|
+
function emit(io, result, asJson, humanPayload) {
|
|
301
|
+
if (!result.ok || asJson || humanPayload === null) {
|
|
302
|
+
printResult(io, result, asJson);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
io.stdout(humanPayload);
|
|
306
|
+
for (const warning of result.warnings) {
|
|
307
|
+
io.stderr(`warning: ${warning}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** `available: false` is reported as such — never as fabricated zeros (C4). */
|
|
311
|
+
function renderMetrics(raw) {
|
|
312
|
+
if (raw['available'] !== true) {
|
|
313
|
+
return `available: false\nreason: ${text(raw['reason']) || 'unavailable'}`;
|
|
314
|
+
}
|
|
315
|
+
const values = (raw['values'] ?? {});
|
|
316
|
+
const lines = Object.entries(values).map(([key, value]) => `${key}: ${String(value)}`);
|
|
317
|
+
return lines.length > 0 ? lines.join('\n') : 'available: true';
|
|
318
|
+
}
|
|
319
|
+
function dispatchId() {
|
|
320
|
+
return process.env['PEAKS_DISPATCH_ID'] ?? 'current';
|
|
321
|
+
}
|
|
322
|
+
function text(value) {
|
|
323
|
+
return typeof value === 'string' ? value : '';
|
|
324
|
+
}
|
|
325
|
+
function count(value) {
|
|
326
|
+
return typeof value === 'number' ? value : 0;
|
|
327
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks web status|stop|install|login` — the daemon-lifecycle, acquisition and
|
|
3
|
+
* persistent-login verbs (slice S2, file 13; S3 adds `install`; S4 adds `login`).
|
|
4
|
+
*
|
|
5
|
+
* Attaches to the `web` parent handed in by `web-commands.ts` rather than
|
|
6
|
+
* looking it up: the lookup needs a fallback branch for "parent not registered
|
|
7
|
+
* yet" that cannot happen here, and one less branch is one less path to test.
|
|
8
|
+
*
|
|
9
|
+
* None of the four goes through the daemon. `status` must work when the daemon
|
|
10
|
+
* is dead or wedged — that IS its job (AC6) — and `stop` must work when the
|
|
11
|
+
* daemon answers nothing at all. Both therefore read the filesystem and the
|
|
12
|
+
* loopback port directly, and both keep working under S3's
|
|
13
|
+
* `PEAKS_WEB_DISABLED` gate (decision C2). `install` is a local download, so it
|
|
14
|
+
* needs no daemon either, and `login` opens its own headed browser (the daemon's
|
|
15
|
+
* is headless) for a user-level profile that is deliberately cross-project
|
|
16
|
+
* (design §10.2) — which is why it needs no session binding.
|
|
17
|
+
*/
|
|
18
|
+
import type { Command } from 'commander';
|
|
19
|
+
import { type ProgramIO } from '../cli-helpers.js';
|
|
20
|
+
export declare function registerWebLifecycleCommands(web: Command, io: ProgramIO): void;
|
|
21
|
+
export declare function runWebStatus(io: ProgramIO, asJson: boolean): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Stop the daemon and clear its records. `stopped` counts the daemons whose
|
|
24
|
+
* process is confirmed GONE by return — not merely the ones we signalled — so a
|
|
25
|
+
* caller that checks the process table right after this returns is not racing
|
|
26
|
+
* the teardown (AC6).
|
|
27
|
+
*/
|
|
28
|
+
export declare function runWebStop(io: ProgramIO, asJson: boolean): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* `peaks web install` — the explicit form of the lazy download, plus R6's
|
|
31
|
+
* recovery path (`--force`).
|
|
32
|
+
*
|
|
33
|
+
* The gate is step 1 of the ordered gate (tech-doc §5.1): checked BEFORE the
|
|
34
|
+
* session lookup, before any lock and before anything that could touch the
|
|
35
|
+
* browser cache, so this verb cannot download under `PEAKS_WEB_DISABLED=1` even
|
|
36
|
+
* if every later step is broken (C2's matrix).
|
|
37
|
+
*/
|
|
38
|
+
export declare function runWebInstall(io: ProgramIO, asJson: boolean, force: boolean): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* `peaks web login --profile <name>` — the only path that persists a login
|
|
41
|
+
* (design §2/§5, PRD R7).
|
|
42
|
+
*
|
|
43
|
+
* `--profile` is enforced HERE rather than with commander's `requiredOption`,
|
|
44
|
+
* because `requiredOption` refuses before this handler runs — and then the
|
|
45
|
+
* `PEAKS_WEB_DISABLED` gate would no longer be statement #1 (tech-doc §5.1,
|
|
46
|
+
* AC5). A profile-less login refuses without touching the profile root: no
|
|
47
|
+
* directory, no storage state, no browser.
|
|
48
|
+
*/
|
|
49
|
+
export declare function runWebLogin(io: ProgramIO, asJson: boolean, rawProfile: string | undefined): Promise<void>;
|