levante 0.1.4 → 0.2.1
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/dist/cli-2pkertsv.js +936 -0
- package/dist/cli-92c6g8hy.js +263 -0
- package/dist/cli-hfteq0z5.js +76 -0
- package/dist/cli-k9cte8rh.js +888 -0
- package/dist/cli-kysyzfpx.js +251 -0
- package/dist/cli-pz51hgax.js +936 -0
- package/dist/cli-r8kj2y2g.js +888 -0
- package/dist/cli-wayz3w81.js +1881 -0
- package/dist/cli-xty0cr8k.js +13612 -0
- package/dist/cli.js +28481 -6174
- package/dist/config/schema.js +13654 -5
- package/dist/index-55bhcbyf.js +19 -0
- package/dist/index-c7tmqtec.js +19 -0
- package/dist/index-d4by4amj.js +19 -0
- package/dist/index-fbvbnntc.js +19 -0
- package/dist/index-pzzp72dd.js +27 -0
- package/dist/index.js +13724 -9
- package/dist/mcp.js +15498 -1565
- package/package.json +10 -10
- package/scripts/auth/setup-auth.mjs +4 -11
- package/scripts/codegen-env.mjs +358 -76
- package/scripts/companion/open-url.mjs +20 -0
- package/scripts/companion/server.mjs +258 -0
- package/scripts/companion/state.mjs +97 -0
- package/scripts/companion/ui/companion.html +681 -0
- package/scripts/companion/ui/pill.js +65 -0
- package/scripts/ui.mjs +98 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "levante",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"levante": "./dist/cli.js",
|
|
@@ -18,25 +18,25 @@
|
|
|
18
18
|
},
|
|
19
19
|
"files": ["dist/", "agents/", "scripts/", "templates/"],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"build": "bun build src/cli.ts src/index.ts src/config/schema.ts src/mcp.ts --outdir dist --target node --format esm --
|
|
21
|
+
"build": "bun build src/cli.ts src/index.ts src/config/schema.ts src/mcp.ts --outdir dist --target node --format esm --packages=bundle",
|
|
22
22
|
"dev": "bun run src/cli.ts"
|
|
23
23
|
},
|
|
24
|
-
"
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@playwright/test": ">=1.40.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
25
28
|
"@inquirer/prompts": "^8.3.0",
|
|
26
29
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
30
|
+
"@qai/cli-auth": "workspace:*",
|
|
31
|
+
"@types/node": "^24.0.14",
|
|
27
32
|
"commander": "^13.1.0",
|
|
28
33
|
"dotenv": "^17.2.0",
|
|
29
34
|
"glob": "^13.0.6",
|
|
30
35
|
"ora": "^9.3.0",
|
|
31
36
|
"picocolors": "^1.1.1",
|
|
37
|
+
"typescript": "^5.8.3",
|
|
38
|
+
"ws": "^8.19.0",
|
|
32
39
|
"yaml": "^2.7.1",
|
|
33
40
|
"zod": "^4.0.5"
|
|
34
|
-
},
|
|
35
|
-
"peerDependencies": {
|
|
36
|
-
"@playwright/test": ">=1.40.0"
|
|
37
|
-
},
|
|
38
|
-
"devDependencies": {
|
|
39
|
-
"@types/node": "^24.0.14",
|
|
40
|
-
"typescript": "^5.8.3"
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -8,10 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* node scripts/auth/setup-auth.mjs # single resource (default)
|
|
11
|
-
* node scripts/auth/setup-auth.mjs --multi # multi resource
|
|
12
11
|
* node scripts/auth/setup-auth.mjs --output .auth/custom.json
|
|
13
12
|
*
|
|
14
|
-
* Credentials come from .env (USERNAME/PASSWORD
|
|
13
|
+
* Credentials come from .env (USERNAME/PASSWORD).
|
|
15
14
|
* Storage state is saved to .auth/codegen.json by default.
|
|
16
15
|
*/
|
|
17
16
|
|
|
@@ -43,21 +42,15 @@ function loadEnv() {
|
|
|
43
42
|
loadEnv();
|
|
44
43
|
|
|
45
44
|
const args = process.argv.slice(2);
|
|
46
|
-
const isMulti = args.includes('--multi');
|
|
47
45
|
const outputIdx = args.indexOf('--output');
|
|
48
46
|
const customOutput = outputIdx !== -1 ? args[outputIdx + 1] : null;
|
|
49
47
|
|
|
50
|
-
const username =
|
|
51
|
-
|
|
52
|
-
: process.env.SINGLE_RESOURCE_USERNAME;
|
|
53
|
-
const password = isMulti
|
|
54
|
-
? process.env.MULTI_RESOURCE_PASSWORD
|
|
55
|
-
: process.env.SINGLE_RESOURCE_PASSWORD;
|
|
48
|
+
const username = process.env.USERNAME;
|
|
49
|
+
const password = process.env.PASSWORD;
|
|
56
50
|
const baseUrl = process.env.BASE_URL;
|
|
57
51
|
|
|
58
52
|
if (!username || !password) {
|
|
59
|
-
|
|
60
|
-
console.error(`Missing ${prefix}_USERNAME or ${prefix}_PASSWORD in .env`);
|
|
53
|
+
console.error(`Missing USERNAME or PASSWORD in .env`);
|
|
61
54
|
process.exit(1);
|
|
62
55
|
}
|
|
63
56
|
if (!baseUrl) {
|
package/scripts/codegen-env.mjs
CHANGED
|
@@ -15,13 +15,33 @@
|
|
|
15
15
|
* E2E_AI_KEY=PROJ-101 node codegen-env.mjs
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
19
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
20
|
-
import { dirname,
|
|
18
|
+
import { execSync, spawn } from 'node:child_process';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import pc from 'picocolors';
|
|
23
|
+
import { printError, printHeader, printSuccess, saveErrorLog } from './ui.mjs';
|
|
22
24
|
|
|
23
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
26
|
|
|
27
|
+
const warnings = [];
|
|
28
|
+
|
|
29
|
+
function isoNow() { return new Date().toISOString(); }
|
|
30
|
+
|
|
31
|
+
// getFixHint is declared here (hoisted function declaration) but only called
|
|
32
|
+
// inside the exit handler, after issueKey is assigned. Safe.
|
|
33
|
+
function getFixHint(msg, key) {
|
|
34
|
+
if (/USERNAME|PASSWORD/i.test(msg))
|
|
35
|
+
return 'Set USERNAME and PASSWORD in .env';
|
|
36
|
+
if (/companion server failed/i.test(msg))
|
|
37
|
+
return 'Re-run with --no-companion to disable companion UI';
|
|
38
|
+
if (/trace replay failed/i.test(msg))
|
|
39
|
+
return `Codegen is saved — run: levante transcribe --key ${key}`;
|
|
40
|
+
if (/voice processing error/i.test(msg))
|
|
41
|
+
return 'Codegen is saved — audio may be incomplete';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
// Project root: prefer env var, then fall back to parent of scripts dir
|
|
26
46
|
const root = process.env.E2E_AI_PROJECT_ROOT || resolve(__dirname, '..');
|
|
27
47
|
|
|
@@ -87,6 +107,7 @@ const url = baseUrl ? baseUrl.replace(/\/$/, '') : 'about:blank';
|
|
|
87
107
|
const rawArgs = process.argv.slice(2);
|
|
88
108
|
const voiceEnabled = !rawArgs.includes('--no-voice');
|
|
89
109
|
const traceEnabled = !rawArgs.includes('--no-trace');
|
|
110
|
+
const companionEnabled = voiceEnabled && !rawArgs.includes('--no-companion');
|
|
90
111
|
const positionalArgs = rawArgs.filter((a) => !a.startsWith('--no-'));
|
|
91
112
|
|
|
92
113
|
const keyInput = positionalArgs[0];
|
|
@@ -106,7 +127,6 @@ const workingDirAbs = isAbsolute(workingDirRel) ? workingDirRel : resolve(root,
|
|
|
106
127
|
const issueDir = resolve(workingDirAbs, issueKey);
|
|
107
128
|
if (!existsSync(issueDir)) {
|
|
108
129
|
mkdirSync(issueDir, { recursive: true });
|
|
109
|
-
console.error(`Created: ${relative(root, issueDir)}/`);
|
|
110
130
|
}
|
|
111
131
|
|
|
112
132
|
const now = new Date();
|
|
@@ -123,11 +143,6 @@ const defaultOut = resolve(issueDir, `codegen-${timestamp}.ts`);
|
|
|
123
143
|
const outputPath = customOut ? resolve(root, customOut) : defaultOut;
|
|
124
144
|
const outputRelative = relative(root, outputPath);
|
|
125
145
|
|
|
126
|
-
console.error(`Issue key: ${issueKey}`);
|
|
127
|
-
console.error(`Recording will be saved to: ${outputRelative}`);
|
|
128
|
-
console.error(`Voice recording: ${voiceEnabled ? 'ENABLED' : 'disabled (--no-voice)'}`);
|
|
129
|
-
console.error(`Trace replay: ${traceEnabled ? 'ENABLED' : 'disabled (--no-trace)'}`);
|
|
130
|
-
console.error('(When you close the Playwright Inspector, the file is written there.)\n');
|
|
131
146
|
|
|
132
147
|
// --- Session timing (always track, used for action timestamps) ---
|
|
133
148
|
const sessionStartTime = Date.now();
|
|
@@ -200,7 +215,146 @@ if (voiceEnabled) {
|
|
|
200
215
|
recording = true;
|
|
201
216
|
segmentIndex++;
|
|
202
217
|
|
|
203
|
-
|
|
218
|
+
// printRecordingStatus(true) is called after the header (see below)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- Companion server setup ---
|
|
222
|
+
let companionProcess = null;
|
|
223
|
+
let companionPort = null;
|
|
224
|
+
let companionWs = null;
|
|
225
|
+
let companionStdoutHandler = null;
|
|
226
|
+
let companionAlreadySaved = null;
|
|
227
|
+
|
|
228
|
+
if (companionEnabled) {
|
|
229
|
+
try {
|
|
230
|
+
const companionScript = resolve(__dirname, 'companion', 'server.mjs');
|
|
231
|
+
const companionRecDir = recordingsDir || resolve(issueDir, 'recordings');
|
|
232
|
+
if (!existsSync(companionRecDir)) mkdirSync(companionRecDir, { recursive: true });
|
|
233
|
+
|
|
234
|
+
companionProcess = spawn('node', [companionScript], {
|
|
235
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
236
|
+
cwd: root,
|
|
237
|
+
env: {
|
|
238
|
+
...process.env,
|
|
239
|
+
COMPANION_OUTPUT_DIR: companionRecDir,
|
|
240
|
+
COMPANION_TIMESTAMP: timestamp,
|
|
241
|
+
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Single stdout handler — dispatches all JSON-line messages
|
|
246
|
+
const onCompanionStdout = [];
|
|
247
|
+
let stdoutBuffer = '';
|
|
248
|
+
companionProcess.stdout.on('data', (chunk) => {
|
|
249
|
+
stdoutBuffer += chunk.toString();
|
|
250
|
+
let newlineIdx;
|
|
251
|
+
while ((newlineIdx = stdoutBuffer.indexOf('\n')) !== -1) {
|
|
252
|
+
const line = stdoutBuffer.slice(0, newlineIdx).trim();
|
|
253
|
+
stdoutBuffer = stdoutBuffer.slice(newlineIdx + 1);
|
|
254
|
+
if (line) {
|
|
255
|
+
try {
|
|
256
|
+
const msg = JSON.parse(line);
|
|
257
|
+
for (const handler of onCompanionStdout) handler(msg);
|
|
258
|
+
} catch {
|
|
259
|
+
process.stderr.write(`[companion] ${line}\n`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Wait for server-started message
|
|
266
|
+
companionPort = await new Promise((resolvePort, rejectPort) => {
|
|
267
|
+
const timeout = setTimeout(() => rejectPort(new Error('Companion server startup timeout')), 5000);
|
|
268
|
+
const handler = (msg) => {
|
|
269
|
+
if (msg.type === 'server-started') {
|
|
270
|
+
clearTimeout(timeout);
|
|
271
|
+
const idx = onCompanionStdout.indexOf(handler);
|
|
272
|
+
if (idx !== -1) onCompanionStdout.splice(idx, 1);
|
|
273
|
+
resolvePort(msg.port);
|
|
274
|
+
} else if (msg.type === 'server-error') {
|
|
275
|
+
clearTimeout(timeout);
|
|
276
|
+
rejectPort(new Error(msg.error));
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
onCompanionStdout.push(handler);
|
|
280
|
+
companionProcess.on('error', (err) => { clearTimeout(timeout); rejectPort(err); });
|
|
281
|
+
companionProcess.on('exit', (code) => { if (code) { clearTimeout(timeout); rejectPort(new Error(`Companion exited with code ${code}`)); } });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
companionStdoutHandler = onCompanionStdout;
|
|
285
|
+
|
|
286
|
+
// Track whether companion already saved (via Save & Close before codegen closed)
|
|
287
|
+
const saveExitTranscriptHandler = (msg) => {
|
|
288
|
+
if (msg.type === 'transcript:saved') {
|
|
289
|
+
companionAlreadySaved = msg.path;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
onCompanionStdout.push(saveExitTranscriptHandler);
|
|
293
|
+
|
|
294
|
+
const saveExitHandler = (msg) => {
|
|
295
|
+
if (msg.type === 'save-and-exit') {
|
|
296
|
+
child.kill('SIGTERM');
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
onCompanionStdout.push(saveExitHandler);
|
|
300
|
+
|
|
301
|
+
// Connect parent to companion WebSocket for hotkey relay
|
|
302
|
+
const { WebSocket: WsClient } = await import('ws');
|
|
303
|
+
companionWs = new WsClient(`ws://localhost:${companionPort}`);
|
|
304
|
+
await new Promise((r) => {
|
|
305
|
+
companionWs.on('open', r);
|
|
306
|
+
companionWs.on('error', r);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
companionWs.on('close', () => {
|
|
310
|
+
companionWs = null;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Listen for companion messages (e.g., user clicked Save & Close)
|
|
314
|
+
companionWs.on('message', (raw) => {
|
|
315
|
+
try {
|
|
316
|
+
const msg = JSON.parse(raw.toString());
|
|
317
|
+
if (msg.type === 'save-and-exit') {
|
|
318
|
+
// User clicked Save & Close in companion — kill codegen to trigger shutdown
|
|
319
|
+
child.kill('SIGTERM');
|
|
320
|
+
}
|
|
321
|
+
} catch {}
|
|
322
|
+
});
|
|
323
|
+
} catch (err) {
|
|
324
|
+
if (err.message.includes('already in use')) {
|
|
325
|
+
printError('Companion port already in use', [
|
|
326
|
+
{ label: 'Fix', value: 'Stop the other session first, then try again' },
|
|
327
|
+
]);
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
warnings.push(`[WARN] ${isoNow()} Companion server failed to start: ${err.message}`);
|
|
331
|
+
warnings.push(`[WARN] ${isoNow()} Continuing without companion UI (voice recording still active)`);
|
|
332
|
+
companionProcess = null;
|
|
333
|
+
companionPort = null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- Print session header (all pre-flight info is now here) ---
|
|
338
|
+
{
|
|
339
|
+
const flags = [
|
|
340
|
+
voiceEnabled ? pc.green('Voice ✓') : pc.dim('Voice ✗'),
|
|
341
|
+
traceEnabled ? pc.green('Trace ✓') : pc.dim('Trace ✗'),
|
|
342
|
+
(companionEnabled && companionPort) ? pc.green('Companion ✓') : pc.dim('Companion ✗'),
|
|
343
|
+
].join(' ');
|
|
344
|
+
|
|
345
|
+
const headerItems = [
|
|
346
|
+
{ label: 'Output', value: outputRelative },
|
|
347
|
+
{ label: 'Status', value: flags },
|
|
348
|
+
];
|
|
349
|
+
if (companionPort) {
|
|
350
|
+
headerItems.push({ label: 'Companion', value: `http://localhost:${companionPort}` });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
printHeader('levante record', issueKey, headerItems);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Print recording status line (was previously printed during voice setup)
|
|
357
|
+
if (voiceEnabled && recording) {
|
|
204
358
|
printRecordingStatus(true);
|
|
205
359
|
}
|
|
206
360
|
|
|
@@ -212,15 +366,15 @@ if (!existsSync(authDir)) {
|
|
|
212
366
|
}
|
|
213
367
|
|
|
214
368
|
if (!existsSync(storageStatePath)) {
|
|
215
|
-
console.error('Authenticating to cache storage state...');
|
|
216
369
|
try {
|
|
217
370
|
execSync(`node "${resolve(__dirname, 'auth', 'setup-auth.mjs')}"`, {
|
|
218
371
|
cwd: root,
|
|
219
|
-
stdio: '
|
|
372
|
+
stdio: 'pipe',
|
|
220
373
|
env: { ...process.env, E2E_AI_PROJECT_ROOT: root },
|
|
221
374
|
});
|
|
222
|
-
} catch {
|
|
223
|
-
|
|
375
|
+
} catch (authErr) {
|
|
376
|
+
const authMsg = (authErr.stderr?.toString().trim() || authErr.message).split('\n')[0];
|
|
377
|
+
warnings.push(`[WARN] ${isoNow()} Auth setup failed: ${authMsg}`);
|
|
224
378
|
}
|
|
225
379
|
}
|
|
226
380
|
|
|
@@ -233,7 +387,6 @@ if (traceEnabled && harPath) {
|
|
|
233
387
|
if (existsSync(storageStatePath)) {
|
|
234
388
|
codegenArgs.push('--load-storage', storageStatePath);
|
|
235
389
|
codegenArgs.push('--save-storage', storageStatePath);
|
|
236
|
-
console.error('Using cached auth \u2014 codegen will start already logged in.');
|
|
237
390
|
}
|
|
238
391
|
codegenArgs.push(url);
|
|
239
392
|
|
|
@@ -246,6 +399,12 @@ const child = spawn('npx', codegenArgs, {
|
|
|
246
399
|
// Start polling the output file for new actions to capture timestamps
|
|
247
400
|
startActionPolling();
|
|
248
401
|
|
|
402
|
+
// --- Open companion window ---
|
|
403
|
+
if (companionPort) {
|
|
404
|
+
const { openUrl } = await import(resolve(__dirname, 'companion', 'open-url.mjs'));
|
|
405
|
+
openUrl(`http://localhost:${companionPort}/companion`);
|
|
406
|
+
}
|
|
407
|
+
|
|
249
408
|
// --- Keyboard listener for pause/resume ---
|
|
250
409
|
if (voiceEnabled && process.stdin.isTTY) {
|
|
251
410
|
process.stdin.setRawMode(true);
|
|
@@ -266,6 +425,9 @@ if (voiceEnabled && process.stdin.isTTY) {
|
|
|
266
425
|
currentRecProcess = null;
|
|
267
426
|
recording = false;
|
|
268
427
|
printRecordingStatus(false);
|
|
428
|
+
if (companionWs && companionWs.readyState === 1) {
|
|
429
|
+
companionWs.send(JSON.stringify({ type: 'control:pause' }));
|
|
430
|
+
}
|
|
269
431
|
} else {
|
|
270
432
|
const segPath = resolve(recordingsDir, `seg-${String(segmentIndex).padStart(3, '0')}.wav`);
|
|
271
433
|
segmentPaths.push(segPath);
|
|
@@ -274,6 +436,9 @@ if (voiceEnabled && process.stdin.isTTY) {
|
|
|
274
436
|
recording = true;
|
|
275
437
|
segmentIndex++;
|
|
276
438
|
printRecordingStatus(true);
|
|
439
|
+
if (companionWs && companionWs.readyState === 1) {
|
|
440
|
+
companionWs.send(JSON.stringify({ type: 'control:resume' }));
|
|
441
|
+
}
|
|
277
442
|
}
|
|
278
443
|
}
|
|
279
444
|
});
|
|
@@ -287,90 +452,207 @@ function cleanupTerminal() {
|
|
|
287
452
|
}
|
|
288
453
|
}
|
|
289
454
|
|
|
455
|
+
function killCompanion() {
|
|
456
|
+
if (companionProcess) {
|
|
457
|
+
try { if (companionWs) companionWs.close(); } catch {}
|
|
458
|
+
try { companionProcess.kill('SIGTERM'); } catch {}
|
|
459
|
+
try { companionProcess.kill('SIGKILL'); } catch {}
|
|
460
|
+
companionProcess = null;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Ensure companion is killed on any exit path
|
|
465
|
+
process.on('exit', killCompanion);
|
|
466
|
+
process.on('SIGINT', () => { killCompanion(); process.exit(130); });
|
|
467
|
+
process.on('SIGTERM', () => { killCompanion(); process.exit(143); });
|
|
468
|
+
process.on('uncaughtException', (err) => {
|
|
469
|
+
printError(err.message || String(err), []);
|
|
470
|
+
killCompanion();
|
|
471
|
+
process.exit(1);
|
|
472
|
+
});
|
|
473
|
+
|
|
290
474
|
child.on('exit', async (code) => {
|
|
291
475
|
cleanupTerminal();
|
|
292
476
|
if (pollInterval) clearInterval(pollInterval);
|
|
293
477
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
478
|
+
try {
|
|
479
|
+
// --- Companion shutdown ---
|
|
480
|
+
let liveTranscriptPath = null;
|
|
481
|
+
if (companionProcess) {
|
|
482
|
+
try {
|
|
483
|
+
if (companionAlreadySaved) {
|
|
484
|
+
// User already clicked Save & Close — transcript was saved before codegen closed
|
|
485
|
+
liveTranscriptPath = companionAlreadySaved;
|
|
486
|
+
if (companionWs) try { companionWs.close(); } catch {}
|
|
487
|
+
companionProcess.kill('SIGTERM');
|
|
488
|
+
} else {
|
|
489
|
+
// Normal shutdown — tell companion to stop and save
|
|
490
|
+
if (companionWs && companionWs.readyState === 1) {
|
|
491
|
+
companionWs.send(JSON.stringify({ type: 'control:stop' }));
|
|
492
|
+
}
|
|
493
|
+
if (companionProcess.stdin?.writable) companionProcess.stdin.write('stop\nsave\n');
|
|
494
|
+
|
|
495
|
+
if (companionStdoutHandler) {
|
|
496
|
+
liveTranscriptPath = await Promise.race([
|
|
497
|
+
new Promise((resolvePromise) => {
|
|
498
|
+
const handler = (msg) => {
|
|
499
|
+
if (msg.type === 'transcript:saved') {
|
|
500
|
+
const idx = companionStdoutHandler.indexOf(handler);
|
|
501
|
+
if (idx !== -1) companionStdoutHandler.splice(idx, 1);
|
|
502
|
+
resolvePromise(msg.path);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
companionStdoutHandler.push(handler);
|
|
506
|
+
}),
|
|
507
|
+
new Promise((resolvePromise) => {
|
|
508
|
+
setTimeout(() => {
|
|
509
|
+
warnings.push(`[WARN] ${isoNow()} Auto-saving transcript (companion did not respond within 30s)`);
|
|
510
|
+
if (companionProcess.stdin?.writable) companionProcess.stdin.write('save\n');
|
|
511
|
+
setTimeout(() => resolvePromise(null), 2000);
|
|
512
|
+
}, 30000);
|
|
513
|
+
}),
|
|
514
|
+
]);
|
|
515
|
+
}
|
|
297
516
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
517
|
+
// Filesystem fallback
|
|
518
|
+
if (!liveTranscriptPath) {
|
|
519
|
+
const expectedPath = resolve(
|
|
520
|
+
recordingsDir || resolve(issueDir, 'recordings'),
|
|
521
|
+
`transcript-${timestamp}.json`
|
|
522
|
+
);
|
|
523
|
+
if (existsSync(expectedPath)) liveTranscriptPath = expectedPath;
|
|
524
|
+
}
|
|
303
525
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
currentRecProcess = null;
|
|
311
|
-
recording = false;
|
|
526
|
+
if (companionWs) try { companionWs.close(); } catch {}
|
|
527
|
+
companionProcess.kill('SIGTERM');
|
|
528
|
+
}
|
|
529
|
+
} catch (err) {
|
|
530
|
+
warnings.push(`[ERROR] ${isoNow()} Companion shutdown error: ${err.message}`);
|
|
531
|
+
try { companionProcess.kill('SIGKILL'); } catch {}
|
|
312
532
|
}
|
|
533
|
+
}
|
|
313
534
|
|
|
314
|
-
|
|
535
|
+
// --- Inject action timestamps (silent) ---
|
|
536
|
+
if (existsSync(outputPath) && actionElapsedSec.length > 0) {
|
|
537
|
+
injectActionTimestamps(outputPath);
|
|
538
|
+
}
|
|
315
539
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
540
|
+
// --- Voice post-processing ---
|
|
541
|
+
if (voiceEnabled && segmentPaths.length > 0) {
|
|
542
|
+
try {
|
|
543
|
+
if (recording && currentRecProcess) {
|
|
544
|
+
const { stopRecording } = await import(resolve(__dirname, 'voice', 'recorder.mjs'));
|
|
545
|
+
await stopRecording(currentRecProcess);
|
|
546
|
+
currentRecProcess = null;
|
|
547
|
+
recording = false;
|
|
548
|
+
}
|
|
320
549
|
|
|
321
|
-
|
|
322
|
-
renameSync(existingSegments[0], mergedWavPath);
|
|
323
|
-
} else {
|
|
324
|
-
console.error(`Merging ${existingSegments.length} audio segments...`);
|
|
325
|
-
const args = ['--combine', 'concatenate', ...existingSegments, mergedWavPath];
|
|
326
|
-
execSync(`sox ${args.map((a) => `"${a}"`).join(' ')}`, { stdio: 'ignore' });
|
|
550
|
+
const existingSegments = segmentPaths.filter((p) => existsSync(p));
|
|
327
551
|
|
|
328
|
-
|
|
329
|
-
|
|
552
|
+
if (existingSegments.length === 0) {
|
|
553
|
+
warnings.push(`[WARN] ${isoNow()} No audio segments recorded`);
|
|
554
|
+
} else {
|
|
555
|
+
const mergedWavPath = resolve(recordingsDir, `voice-${timestamp}.wav`);
|
|
556
|
+
if (existingSegments.length === 1) {
|
|
557
|
+
renameSync(existingSegments[0], mergedWavPath);
|
|
558
|
+
} else {
|
|
559
|
+
const soxArgs = ['--combine', 'concatenate', ...existingSegments, mergedWavPath];
|
|
560
|
+
execSync(`sox ${soxArgs.map((a) => `"${a}"`).join(' ')}`, { stdio: 'ignore' });
|
|
561
|
+
for (const seg of existingSegments) {
|
|
562
|
+
try { unlinkSync(seg); } catch {}
|
|
563
|
+
}
|
|
330
564
|
}
|
|
331
565
|
}
|
|
566
|
+
} catch (err) {
|
|
567
|
+
warnings.push(`[ERROR] ${isoNow()} Voice processing error: ${err.message}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
332
570
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
571
|
+
// --- Write session metadata ---
|
|
572
|
+
if (voiceEnabled && existsSync(outputPath)) {
|
|
573
|
+
const meta = {
|
|
574
|
+
codegen: `codegen-${timestamp}.ts`,
|
|
575
|
+
audio: recordingsDir ? `recordings/voice-${timestamp}.wav` : null,
|
|
576
|
+
transcript: liveTranscriptPath ? relative(issueDir, liveTranscriptPath) : null,
|
|
577
|
+
liveTranscription: !!liveTranscriptPath,
|
|
578
|
+
engine: 'webspeech',
|
|
579
|
+
duration: null,
|
|
580
|
+
segmentCount: null,
|
|
581
|
+
tagCount: null,
|
|
582
|
+
};
|
|
583
|
+
if (liveTranscriptPath && existsSync(liveTranscriptPath)) {
|
|
584
|
+
try {
|
|
585
|
+
const transcript = JSON.parse(readFileSync(liveTranscriptPath, 'utf-8'));
|
|
586
|
+
meta.duration = transcript.duration;
|
|
587
|
+
meta.segmentCount = transcript.segments?.length ?? 0;
|
|
588
|
+
meta.tagCount = transcript.segments?.reduce((n, s) => n + (s.tags?.length ?? 0), 0) ?? 0;
|
|
589
|
+
meta.engine = transcript.engine || 'webspeech';
|
|
590
|
+
} catch {}
|
|
337
591
|
}
|
|
338
|
-
|
|
339
|
-
console.error(`\nVoice processing error: ${err.message}`);
|
|
592
|
+
writeFileSync(resolve(issueDir, 'session-meta.json'), JSON.stringify(meta, null, 2));
|
|
340
593
|
}
|
|
341
|
-
}
|
|
342
594
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
595
|
+
// --- Trace injection + replay ---
|
|
596
|
+
if (existsSync(outputPath)) {
|
|
597
|
+
const codegenSrc = readFileSync(outputPath, 'utf-8');
|
|
598
|
+
if (!codegenSrc.includes("test.use({ trace: 'on' })")) {
|
|
599
|
+
const injected = codegenSrc.replace(
|
|
600
|
+
/^(import\s.*from\s+['"]@playwright\/test['"];?\s*\n)/m,
|
|
601
|
+
"$1\ntest.use({ trace: 'on' });\n",
|
|
602
|
+
);
|
|
603
|
+
if (injected !== codegenSrc) writeFileSync(outputPath, injected);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (traceEnabled) {
|
|
607
|
+
try {
|
|
608
|
+
const replayScript = resolve(__dirname, 'trace', 'replay-with-trace.mjs');
|
|
609
|
+
execSync(`node "${replayScript}" "${outputRelative}"`, {
|
|
610
|
+
cwd: root,
|
|
611
|
+
stdio: 'inherit',
|
|
612
|
+
env: { ...process.env, E2E_AI_PROJECT_ROOT: root },
|
|
613
|
+
});
|
|
614
|
+
} catch {
|
|
615
|
+
warnings.push(`[WARN] ${isoNow()} Trace replay failed (codegen file is still saved)`);
|
|
616
|
+
}
|
|
354
617
|
}
|
|
355
618
|
}
|
|
356
619
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
console.error('Trace replay failed (codegen file is still saved).');
|
|
620
|
+
// --- Print outcome card ---
|
|
621
|
+
const hasErrors = warnings.some((w) => w.startsWith('[ERROR]'));
|
|
622
|
+
const hasWarnings = warnings.length > 0;
|
|
623
|
+
const metaPath = relative(root, resolve(issueDir, 'session-meta.json'));
|
|
624
|
+
|
|
625
|
+
if (code === 0 && !hasErrors) {
|
|
626
|
+
const successItems = [{ label: 'Details', value: metaPath }];
|
|
627
|
+
if (hasWarnings) {
|
|
628
|
+
const logPath = saveErrorLog(issueDir, 'record-error.log', warnings);
|
|
629
|
+
if (logPath) successItems.push({ label: 'Warnings', value: relative(root, logPath) });
|
|
368
630
|
}
|
|
631
|
+
successItems.push({ label: 'Next', value: `levante transcribe --key ${issueKey}` });
|
|
632
|
+
if (harPath && existsSync(harPath)) {
|
|
633
|
+
successItems.push({ label: 'HAR', value: relative(root, harPath) });
|
|
634
|
+
}
|
|
635
|
+
printSuccess('Session saved', successItems);
|
|
636
|
+
} else {
|
|
637
|
+
const firstError = warnings.find((w) => w.startsWith('[ERROR]'));
|
|
638
|
+
const primaryMsg = firstError
|
|
639
|
+
? firstError.replace(/^\[ERROR\] \S+ /, '')
|
|
640
|
+
: `Recording exited with code ${code}`;
|
|
641
|
+
const logEntries = warnings.length
|
|
642
|
+
? warnings
|
|
643
|
+
: [`[ERROR] ${isoNow()} Recording exited with code ${code}`];
|
|
644
|
+
const logPath = saveErrorLog(issueDir, 'record-error.log', logEntries);
|
|
645
|
+
const errorItems = [];
|
|
646
|
+
if (logPath) errorItems.push({ label: 'Log', value: relative(root, logPath) });
|
|
647
|
+
const hint = getFixHint(primaryMsg, issueKey);
|
|
648
|
+
if (hint) errorItems.push({ label: 'Fix', value: hint });
|
|
649
|
+
printError(primaryMsg, errorItems);
|
|
369
650
|
}
|
|
370
|
-
}
|
|
371
651
|
|
|
372
|
-
|
|
373
|
-
|
|
652
|
+
} catch (fatalErr) {
|
|
653
|
+
warnings.push(`[ERROR] ${isoNow()} Fatal exit handler error: ${fatalErr.message}`);
|
|
654
|
+
const logPath = saveErrorLog(issueDir, 'record-error.log', warnings);
|
|
655
|
+
printError(fatalErr.message, logPath ? [{ label: 'Log', value: relative(root, logPath) }] : []);
|
|
374
656
|
}
|
|
375
657
|
|
|
376
658
|
process.exit(code ?? 0);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function openUrl(url) {
|
|
4
|
+
const platform = process.platform;
|
|
5
|
+
let cmd, args;
|
|
6
|
+
|
|
7
|
+
if (platform === 'darwin') {
|
|
8
|
+
cmd = 'open';
|
|
9
|
+
args = [url];
|
|
10
|
+
} else if (platform === 'win32') {
|
|
11
|
+
cmd = 'cmd';
|
|
12
|
+
args = ['/c', 'start', '', url];
|
|
13
|
+
} else {
|
|
14
|
+
cmd = 'xdg-open';
|
|
15
|
+
args = [url];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
19
|
+
child.unref();
|
|
20
|
+
}
|