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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
returnpath.join(TEMP_DIR,`gstack-recording-${stamp}.mp4`);
returnpath.join(process.cwd(),'.gstack','qa-reports','recordings',`qa-${stamp}.mp4`);

}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
returnpath.join(TEMP_DIR,`gstack-recording-${stamp}.mp4`);
returnpath.join(process.cwd(),'.gstack','qa-reports','recordings',`qa-${stamp}.mp4`);

}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
returnpath.join(TEMP_DIR,`gstack-recording-${stamp}.mp4`);
returnpath.join(process.cwd(),'.gstack','qa-reports','recordings',`qa-${stamp}.mp4`);

}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
returnpath.join(TEMP_DIR,`gstack-recording-${stamp}.mp4`);
returnpath.join(process.cwd(),'.gstack','qa-reports','recordings',`qa-${stamp}.mp4`);

}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
returnpath.join(TEMP_DIR,`gstack-recording-${stamp}.mp4`);
returnpath.join(process.cwd(),'.gstack','qa-reports','recordings',`qa-${stamp}.mp4`);

}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
returnpath.join(TEMP_DIR,`gstack-recording-${stamp}.mp4`);
returnpath.join(process.cwd(),'.gstack','qa-reports','recordings',`qa-${stamp}.mp4`);

}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
returnpath.join(TEMP_DIR,`gstack-recording-${stamp}.mp4`);
returnpath.join(process.cwd(),'.gstack','qa-reports','recordings',`qa-${stamp}.mp4`);

}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
returnpath.join(TEMP_DIR,`gstack-recording-${stamp}.mp4`);
returnpath.join(process.cwd(),'.gstack','qa-reports','recordings',`qa-${stamp}.mp4`);

}
Loading
Loading