Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ installs six discoverable skills: the five judgment dispatchers plus make-pdf.
| Skill | Primary responsibility |
|---|---|
| `/plan` | Product framing, CEO scope, engineering architecture, DX, autoplan, executable specs, and planning preferences. |
| `/qa` | Report-only or fix-and-verify web QA, physical-iOS QA, DX journeys, performance, and canaries. |
| `/qa` | Report-only or fix-and-verify web QA, physical-iOS QA, DX journeys, performance, canaries, and recorded agent walkthroughs. |
| `/debug` | Root-cause investigation, physical-iOS fixes, and internal safety controls. |
| `/review` | Diff, security, repository-health, and independent outside-voice review. |
| `/ship` | PR preparation, landing/deployment, queue inspection, release docs, upgrade, and internal iOS release operations. |
Expand Down Expand Up @@ -39,7 +39,7 @@ Its question order, pressure, smart skips, STOP/approval gates, evidence,
artifacts, mutation boundary, exit behavior, and voice are binding. Preserve
report-only versus fix behavior. List skipped primary modules and why.

The exhaustive 55-command compatibility map is in
The exhaustive 56-command compatibility map is in
[`docs/gstack-2/SKILL-MIGRATION.md`](docs/gstack-2/SKILL-MIGRATION.md). Old
names are opt-in routing aliases and must print their replacement invocation;
they contain no copied judgment. Representative mappings:
Expand All @@ -50,6 +50,7 @@ they contain no copied judgment. Representative mappings:
| `/plan-ceo-review` | `/plan --mode ceo` |
| `/plan-eng-review` | `/plan --mode eng` |
| `/qa-only` | `/qa --mode report` |
| `/recording` | `/qa --mode Report --module recording` |
| `/investigate` | `/debug --mode investigate` |
| `/cso` | `/review --mode security` |
| `/land-and-deploy` | `/ship --mode land` |
Expand Down
3 changes: 3 additions & 0 deletions browse/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const META_COMMANDS = new Set([
'connect', 'disconnect', 'focus',
'inbox',
'watch',
'record',
'state',
'frame',
'ux-audit',
Expand Down Expand Up @@ -175,6 +176,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
'inbox': { category: 'Meta', description: 'List messages from sidebar scout inbox', usage: 'inbox [--clear]' },
// Watch
'watch': { category: 'Meta', description: 'Passive observation — periodic snapshots while user browses', usage: 'watch [stop]' },
'record': { category: 'Visual', description: 'Screen-record the agent driving the current page. start writes JPEG frames via CDP screencast; stop encodes to mp4 (ffmpeg) or an HTML player and can open it. Not available over the pair-agent tunnel.', usage: 'record start [path] [--fps N] [--quality Q] | record stop [--open] [--keep-frames] | record status | record open [path]' },
// State
'state': { category: 'Server', description: 'Save/load browser state (cookies + URLs)', usage: 'state save|load <name>' },
// Frame
Expand Down Expand Up @@ -232,6 +234,7 @@ export function canonicalizeCommand(cmd: string): string {
*/
export const NEW_IN_VERSION: Record<string, string> = {
'load-html': '0.19.0.0',
'record': '2.0.0.0',
};

/**
Expand Down
5 changes: 5 additions & 0 deletions browse/src/meta-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,11 @@ export async function handleMetaCommand(
return await handleMemoryCommand(args, bm);
}

case 'record': {
const { handleRecordCommand } = await import('./screencast');
return await handleRecordCommand(args, bm);
}

default:
throw new Error(`Unknown meta command: ${command}`);
}
Expand Down
176 changes: 176 additions & 0 deletions browse/src/screencast-encode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Encode a JPEG frame directory into a playable recording, and open it.
*
* ffmpeg is optional. When it is missing or fails, we write a self-contained
* HTML player that steps through the frames at the captured fps so the user
* still gets something they can open. No Playwright, no CDP.
*/

import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR } from './platform';

export const FRAME_PATTERN_JPG = 'frame_%06d.jpg';
export const FRAME_PATTERN_PNG = 'frame_%06d.png';

export interface EncodeResult {
artifactPath: string;
kind: 'mp4' | 'webm' | 'html';
frameCount: number;
ffmpeg: boolean;
}

function listFrames(framesDir: string): { files: string[]; pattern: string } {
if (!fs.existsSync(framesDir)) return { files: [], pattern: FRAME_PATTERN_JPG };
const names = fs.readdirSync(framesDir);
const jpgs = names.filter((f) => /^frame_\d{6}\.jpe?g$/i.test(f)).sort();
if (jpgs.length) return { files: jpgs, pattern: FRAME_PATTERN_JPG };
const pngs = names.filter((f) => /^frame_\d{6}\.png$/i.test(f)).sort();
return { files: pngs, pattern: FRAME_PATTERN_PNG };
}

/** True when `ffmpeg` is on PATH. Pure probe — never throws. */
export function ffmpegAvailable(): boolean {
try {
const r = spawnSync('ffmpeg', ['-version'], {
stdio: 'ignore',
timeout: 4000,
windowsHide: true,
});
return r.status === 0;
} catch {
return false;
}
}

function writeHtmlPlayer(framesDir: string, frames: string[], fps: number, dest: string): void {
const safeFps = Number.isFinite(fps) && fps > 0 ? fps : 8;
const relFrames = frames.map((f) => path.basename(f));
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>gstack recording</title>
<style>
body { margin: 0; background: #111; color: #eee; font: 14px/1.4 system-ui, sans-serif; }
main { max-width: 1100px; margin: 0 auto; padding: 16px; }
img { width: 100%; background: #000; display: block; }
.bar { display: flex; gap: 12px; align-items: center; margin-top: 12px; }
button { font: inherit; padding: 6px 12px; }
</style>
</head>
<body>
<main>
<img id="frame" alt="recording frame">
<div class="bar">
<button id="toggle" type="button">Pause</button>
<span id="meta"></span>
</div>
</main>
<script>
const frames = ${JSON.stringify(relFrames)};
const fps = ${safeFps};
let i = 0;
let playing = true;
const img = document.getElementById('frame');
const meta = document.getElementById('meta');
const toggle = document.getElementById('toggle');
function show() {
if (!frames.length) { meta.textContent = 'No frames'; return; }
img.src = frames[i];
meta.textContent = (i + 1) + ' / ' + frames.length + ' @ ' + fps + ' fps';
}
function tick() {
if (!playing || frames.length === 0) return;
i = (i + 1) % frames.length;
show();
}
toggle.addEventListener('click', () => {
playing = !playing;
toggle.textContent = playing ? 'Pause' : 'Play';
});
show();
setInterval(tick, Math.max(20, Math.round(1000 / fps)));
</script>
</body>
</html>
`;
fs.writeFileSync(dest, html);
}

function runFfmpeg(framesDir: string, fps: number, outputPath: string, pattern: string): boolean {
const input = path.join(framesDir, pattern);
const attempts: string[][] = [
['-y', '-hide_banner', '-loglevel', 'error', '-framerate', String(fps), '-i', input,
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', outputPath],
['-y', '-hide_banner', '-loglevel', 'error', '-framerate', String(fps), '-i', input,
'-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', outputPath],
];
for (const args of attempts) {
try {
const r = spawnSync('ffmpeg', args, {
stdio: 'ignore',
timeout: 120_000,
windowsHide: true,
});
if (r.status === 0 && fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) {
return true;
}
} catch {
// try next encoder
}
}
return false;
}

/**
* Turn numbered JPEG frames into a playable artifact next to `outputPath`.
* Prefers mp4 via ffmpeg; falls back to an HTML player in the frame directory.
*/
export function encodeFrameDirectory(
framesDir: string,
outputPath: string,
fps: number,
): EncodeResult {
const { files: frames, pattern } = listFrames(framesDir);
if (frames.length === 0) {
throw new Error(`No recording frames in ${framesDir}`);
}

const ext = path.extname(outputPath).toLowerCase();
const videoPath = ext === '.webm' || ext === '.mp4' ? outputPath : outputPath.replace(/\.[^.]+$/, '') + '.mp4';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When record start receives an extensionless path inside a dot-named directory, this regex treats the directory as the extension and writes the MP4 elsewhere. Derive the extension from path.parse(outputPath) or the basename instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/screencast-encode.ts, line 142:

<comment>When `record start` receives an extensionless path inside a dot-named directory, this regex treats the directory as the extension and writes the MP4 elsewhere. Derive the extension from `path.parse(outputPath)` or the basename instead.</comment>

<file context>
@@ -0,0 +1,176 @@
+  }
+
+  const ext = path.extname(outputPath).toLowerCase();
+  const videoPath = ext === '.webm' || ext === '.mp4' ? outputPath : outputPath.replace(/\.[^.]+$/, '') + '.mp4';
+
+  if (ffmpegAvailable() && runFfmpeg(framesDir, fps, videoPath, pattern)) {
</file context>


if (ffmpegAvailable() && runFfmpeg(framesDir, fps, videoPath, pattern)) {
const kind = path.extname(videoPath).toLowerCase() === '.webm' ? 'webm' : 'mp4';
return { artifactPath: videoPath, kind, frameCount: frames.length, ffmpeg: true };
}

const htmlPath = path.join(framesDir, 'player.html');
writeHtmlPlayer(framesDir, frames, fps, htmlPath);
return { artifactPath: htmlPath, kind: 'html', frameCount: frames.length, ffmpeg: false };
}

/** Open a file with the platform viewer. Best-effort; never throws. */
export function openArtifact(filePath: string): boolean {
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) return false;
const cmd =
process.platform === 'darwin' ? ['open', resolved]
: process.platform === 'win32' ? ['cmd', '/c', 'start', '', resolved]
: ['xdg-open', resolved];
try {
const r = spawnSync(cmd[0], cmd.slice(1), {
stdio: 'ignore',
timeout: 8000,
windowsHide: true,
});
return r.status === 0;
} catch {
return false;
}
}

export function defaultRecordingPath(stamp = Date.now()): string {
return path.join(TEMP_DIR, `gstack-recording-${stamp}.mp4`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: record start without a path calls defaultRecordingPath(), which returns a /tmp file. The recording module documents .gstack/qa-reports/recordings/qa-<stamp>.mp4 as the default, so the default workflow bypasses the QA report artifact directory. Return the documented report path or update the module contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/screencast-encode.ts, line 175:

<comment>`record start` without a path calls `defaultRecordingPath()`, which returns a `/tmp` file. The recording module documents `.gstack/qa-reports/recordings/qa-<stamp>.mp4` as the default, so the default workflow bypasses the QA report artifact directory. Return the documented report path or update the module contract.</comment>

<file context>
@@ -0,0 +1,176 @@
+}
+
+export function defaultRecordingPath(stamp = Date.now()): string {
+  return path.join(TEMP_DIR, `gstack-recording-${stamp}.mp4`);
+}
</file context>
Suggested change
return path.join(TEMP_DIR, `gstack-recording-${stamp}.mp4`);
return path.join(process.cwd(), '.gstack', 'qa-reports', 'recordings', `qa-${stamp}.mp4`);

}
Loading
Loading