From 7777b1855e0dc63cb524567c995e1c107d99c367 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 18:30:00 +1100 Subject: [PATCH] feat(demos): record visual Skills with Codex effort --- COPY.md | 2 +- layers/registry/app/components/_SkillDemo.vue | 7 +- layers/registry/server/data/skill-demos.json | 275 ++++++++++++++++++ .../understand/diagram.html | 84 ++++++ .../higagentskills/higagentskills/index.html | 82 ++++++ .../visual-explainer/diagram.html | 150 ++++++++++ .../server/demos/shadcn/ui/shadcn/index.html | 99 +++++++ .../demos/vercel-labs/vgpu/vgpu/scene.html | 197 +++++++++++++ layers/registry/server/utils/skill-demos.ts | 5 + scripts/lib/demo-agent.ts | 24 ++ scripts/record-skill-demo.ts | 115 ++++++-- shared/demo-recording.ts | 9 + test/fixtures/demo-agent-stdin.ts | 4 + test/unit/demo-agent.test.ts | 17 ++ test/unit/demo-recording.test.ts | 12 +- test/unit/skill-demos.test.ts | 4 + 16 files changed, 1062 insertions(+), 24 deletions(-) create mode 100644 layers/registry/server/demos/egonex-ai/understand-anything/understand/diagram.html create mode 100644 layers/registry/server/demos/justinwetch/higagentskills/higagentskills/index.html create mode 100644 layers/registry/server/demos/nicobailon/visual-explainer/visual-explainer/diagram.html create mode 100644 layers/registry/server/demos/shadcn/ui/shadcn/index.html create mode 100644 layers/registry/server/demos/vercel-labs/vgpu/vgpu/scene.html create mode 100644 scripts/lib/demo-agent.ts create mode 100644 test/fixtures/demo-agent-stdin.ts create mode 100644 test/unit/demo-agent.test.ts diff --git a/COPY.md b/COPY.md index 64751595..6029172c 100644 --- a/COPY.md +++ b/COPY.md @@ -46,7 +46,7 @@ These exact strings. Do not paraphrase them per page. | Demo groups | Films and launch videos · Landing pages · UI components · Diagrams and explainers | guidance only: `DEMO_GROUPS` in `shared/demo-groups.ts`: the `/skills/demos` group headings and their lines | | Demo stage | Open the demo | guidance only: the demo stage link. On the homepage it opens the demo page; on a demo page it opens the Skill page Demo panel | | Demo panel | Demo · Prompt · Open live demo · Open in a new tab · Run it yourself · All demos | guidance only: the Skill page Demo panel. "Show screenshots" replaces "Open live demo" while the live output shows. "All demos" opens the demo page. It hides for a Skill page only demo, and while the Skill holds a run check flag | -| Demo provenance | {Agent logo} {model}, such as Opus 5.5 | guidance only: the recording line on the Skill page panel and the homepage stage. Screen readers and the title get "Recorded with {agent}, {model}". No date and no GitHub link: the Skill page is where visitors inspect a Skill | +| Demo provenance | {Agent logo} {model}, {effort} effort | guidance only: the recording line on the Skill page panel and the homepage stage. Screen readers and the title get "Recorded with {agent}, {model}, {effort} effort". Omit effort when the recording has no evidence for it. No date and no GitHub link: the Skill page is where visitors inspect a Skill | | Demo outdated | Recorded on an older version of this Skill. | guidance only: under the Demo panel when the Skill moved past the recorded commit | | Home section: changes | Keep up with skill changes. | guidance only: the homepage section heading for watching and the digest | | Home section: why | What skilld does differently. | guidance only: the homepage Why band heading, after trending. No line under it: the three columns from `HOME_WHY_REASONS` follow directly, each a title, `summary`, and picture. Their skills.sh lines stay on `/vs/skills-sh` | diff --git a/layers/registry/app/components/_SkillDemo.vue b/layers/registry/app/components/_SkillDemo.vue index 29327e71..6143b7ad 100644 --- a/layers/registry/app/components/_SkillDemo.vue +++ b/layers/registry/app/components/_SkillDemo.vue @@ -1,7 +1,7 @@ \ No newline at end of file diff --git a/layers/registry/server/demos/justinwetch/higagentskills/higagentskills/index.html b/layers/registry/server/demos/justinwetch/higagentskills/higagentskills/index.html new file mode 100644 index 00000000..ffaeb7a1 --- /dev/null +++ b/layers/registry/server/demos/justinwetch/higagentskills/higagentskills/index.html @@ -0,0 +1,82 @@ + + + + + + +Foxglove · A little time for a good book + + + + + +
+
foxglove
+
+
Make room for reading

A little time. A good book.

Settle in, turn a page, and let the world wait.

+
+
+
Your reading sessionA moment for you
+
25:00
Ready when you are
+
Session length
+
+

No rush. Every minute counts.

+
Currently reading

The Secret Garden

Frances Hodgson Burnett

+

+
+
+

A little more, every day

0/ 30 min today

Your next chapter starts with a little time.

One page is a lovely place to start.

+

On your nightstand

3 books

Where will you wander today?

+ +
+
+
Small moments. Lasting stories.Just you and your next chapter.
+
+

Choose a book

+

Your daily goal

Make a little space that fits your day.

+ + + + diff --git a/layers/registry/server/demos/nicobailon/visual-explainer/visual-explainer/diagram.html b/layers/registry/server/demos/nicobailon/visual-explainer/visual-explainer/diagram.html new file mode 100644 index 00000000..f62678f0 --- /dev/null +++ b/layers/registry/server/demos/nicobailon/visual-explainer/visual-explainer/diagram.html @@ -0,0 +1,150 @@ + + + + + + +From seed to shared seed · Community seed library + + + + + + +
+
Community seed library
A season of sharing
+

Borrow seeds.
Grow the next collection.

Plant borrowed seeds. Save new seeds from your harvest. Return them for the next grower.

+
+

The seeds come full circle

Select a step to see what happens.
+
+
    +
  1. choose
  2. +
  3. sow
  4. +
  5. mature
  6. +
  7. return
  8. +
  9. review
  10. +
  11. +
+ +
+
Fig. 1The return is a new generation of seeds, linked to the original variety.
+
+
+
At the library · Step 1 of 6

Choose seeds that suit your garden

Browse by crop, variety, planting season, and seed-saving difficulty. Start with an open-pollinated variety.

A batch groups seeds with the same source and harvest. Keep its label with your packet.

1 / 6
+

Catalog record

Illustrative record
Crop / variety
Bean / Blue Lake
Batch
BEAN-A
Seed type
Open-pollinated
Availability
Packets available

The catalog shows stock ready to lend.

+
+ +
+
One growing season

A harvest becomes next season’s stock

+
    +
  1. +
  2. +
  3. +
  4. +
+

Example: a warm-season annual crop in a temperate climate. Local weather and each crop set the actual dates.

+
Fig. 2Seed harvest follows seed maturity, which can occur after the food harvest.
+
+ + + + +
+ + + diff --git a/layers/registry/server/demos/shadcn/ui/shadcn/index.html b/layers/registry/server/demos/shadcn/ui/shadcn/index.html new file mode 100644 index 00000000..6787e911 --- /dev/null +++ b/layers/registry/server/demos/shadcn/ui/shadcn/index.html @@ -0,0 +1,99 @@ +Book a firing · Fernhaven
\ No newline at end of file diff --git a/layers/registry/server/demos/vercel-labs/vgpu/vgpu/scene.html b/layers/registry/server/demos/vercel-labs/vgpu/vgpu/scene.html new file mode 100644 index 00000000..7068422d --- /dev/null +++ b/layers/registry/server/demos/vercel-labs/vgpu/vgpu/scene.html @@ -0,0 +1,197 @@ + + + + + + +Prism Pond · A place to pause + + + +
+ +
+
SMALL WORLDS / 001
WAKING THE POND
+

A PLACE TO PAUSE

Prism
Pond

A quiet constellation of floating islands.
Touch one. Watch the colour travel.

Nothing to finish.
Just a little room to play.

+

YOUR COLOUR

Jade

A soft light on still water.

+
+
Drag to orbitScroll to get closer
+
+

Arrow keys orbit. Plus and minus zoom. Keys 1 to 4 choose a colour. Enter sends a ripple. Space pauses.

+ +
Finding a little stillness…
+ + +
+ + + diff --git a/layers/registry/server/utils/skill-demos.ts b/layers/registry/server/utils/skill-demos.ts index 5f371ece..09ecc3c9 100644 --- a/layers/registry/server/utils/skill-demos.ts +++ b/layers/registry/server/utils/skill-demos.ts @@ -1,7 +1,9 @@ import type { DemoMakes } from '#shared/demo-groups' +import type { DemoEffort } from '#shared/demo-recording' import { z } from 'zod' import { DEMO_MAKES } from '#shared/demo-groups' import { demoPagePath, DEMOS_PATH, MIN_INDEXABLE_DEMOS } from '#shared/demo-pages' +import { DEMO_EFFORTS } from '#shared/demo-recording' import { runCheckFlagKey } from '#shared/run-check-flags' import { repoSkillPath } from '#shared/skill-routes' import manifest from '../data/skill-demos.json' @@ -71,6 +73,7 @@ const demoSchema = z.object({ agent: z.string().min(1), agentVersion: z.string().min(1), model: z.string().min(1), + effort: z.enum(DEMO_EFFORTS).optional(), /** The Skill's source commit when the demo was recorded. */ skillCommit: z.string().regex(/^[0-9a-f]{40}$/), recordedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), @@ -176,6 +179,7 @@ export interface SkillDemoView { setup: string | null agent: string model: string + effort: DemoEffort | null skillCommit: string recordedAt: string /** True when the Skill moved past the commit the demo recorded. */ @@ -204,6 +208,7 @@ export function presentSkillDemo(demo: SkillDemoRecord, currentCommit: string | setup: demo.setup ?? null, agent: demo.agent, model: demo.model, + effort: demo.effort ?? null, skillCommit: demo.skillCommit, recordedAt: demo.recordedAt, outdated: currentCommit !== null && currentCommit !== demo.skillCommit, diff --git a/scripts/lib/demo-agent.ts b/scripts/lib/demo-agent.ts new file mode 100644 index 00000000..bd8c2ca5 --- /dev/null +++ b/scripts/lib/demo-agent.ts @@ -0,0 +1,24 @@ +import { execFile } from 'node:child_process' +import { createWriteStream } from 'node:fs' +import { join } from 'node:path' +import { finished } from 'node:stream/promises' +import { promisify } from 'node:util' + +const run = promisify(execFile) + +/** Keep live evidence and close the input stream before waiting for an Agent. */ +export async function runDemoAgent(input: { command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, timeout: number }): Promise<{ stdout: string }> { + const eventsLog = createWriteStream(join(input.cwd, 'codex-events.jsonl')) + const stderrLog = createWriteStream(join(input.cwd, 'codex-stderr.log')) + const execution = run(input.command, input.args, { + cwd: input.cwd, + env: input.env, + timeout: input.timeout, + maxBuffer: 64 * 1024 * 1024, + }) + execution.child.stdin?.end() + execution.child.stdout?.pipe(eventsLog) + execution.child.stderr?.pipe(stderrLog) + const [{ stdout }] = await Promise.all([execution, finished(eventsLog), finished(stderrLog)]) + return { stdout } +} diff --git a/scripts/record-skill-demo.ts b/scripts/record-skill-demo.ts index 016328e8..e6602926 100644 --- a/scripts/record-skill-demo.ts +++ b/scripts/record-skill-demo.ts @@ -5,6 +5,7 @@ * pnpm demo:record owner/repo/skill --makes film --prompt "Make ... Render it as film.mp4." --output film.mp4 * pnpm demo:record owner/repo/skill --makes film --prompt "..." --output launch.mp4 --seed ./site --setup "The folder held ..." * pnpm demo:record owner/repo/skill --makes diagram --prompt "..." --output diagram.html --shell + * pnpm demo:record owner/repo/skill --makes game --prompt "..." --output game.html --agent codex --model gpt-6.1-sol --effort medium * * `--makes` is the demo's group on /skills/demos: one of DEMO_MAKES in * shared/demo-groups.ts. `--skill-page-only` keeps the demo on its Skill page @@ -14,7 +15,7 @@ * pnpm demo:record owner/repo/skill --makes slides --prompt "..." --output deck.html --resume /tmp/skilld-demo-XXXX --model claude-opus-5-5 * * 1. Reads the Skill's current source commit from the public API. - * 2. Runs Claude Code headless in a fresh temp folder: it loads the Skill with + * 2. Runs Claude Code or Codex headless in a fresh temp folder: it loads the Skill with * `npx skilld run`, then gets the prompt word for word. `--seed` copies a * folder in first, and `--setup` says so on the demo. * 3. An HTML output gets full-page screenshots at desktop width, and at phone @@ -26,25 +27,32 @@ * `server/demos/`, and upserts the entry in `server/data/skill-demos.json`. * The account comes from `CLOUDFLARE_ACCOUNT_ID`, else `wrangler.jsonc`. * - * A page Skill gets only Read, Write and Edit. A video Skill has to run its + * In Claude Code, a page Skill gets only Read, Write and Edit. A video Skill has to run its * renderer, so it gets a shell. `--shell` gives a page Skill the same shell, for * a Skill that checks its own output with a command; without it, that check is - * skipped and a broken page can ship. Every shell runs in the Claude Code + * skipped and a broken page can ship. These shells run in the Claude Code * sandbox: writes stay in the temp folder, secrets and agent sockets stay * unreadable, the environment carries no keys, and the network reaches only * package and code hosts. Third-party Skill text never gets an open shell on * this machine. + * Codex uses a named permission profile with the same private-path denials + * and package hosts. It can run a shell and local renderer servers. Its model + * and effort are explicit. Run events stay beside the output for inspection. + * `--allow` passes only behaviors the user approved for this Skill. + * `--source` loads an exact hosted selector from the same Repository when + * the registry name differs from the name in the source frontmatter. * * `--reshoot` retakes the screenshots of a page demo from its kept output, * without running the Agent again. `--resume` finishes a run whose Agent * already wrote its output into a folder, when a later step failed; give the - * model that run used with `--model`, since its result was not kept. + * model that run used with `--model`, and its `--agent` and `--effort`. * * It writes nothing to production. Opening a pull request with the result is * the review step: a human approves each demo by merging it. */ import type { DemoMakes } from '../shared/demo-groups' +import type { DemoEffort } from '../shared/demo-recording' import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, readdirSync } from 'node:fs' @@ -56,6 +64,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { parseArgs, promisify } from 'node:util' import { chromium } from '@playwright/test' import { DEMO_MAKES } from '../shared/demo-groups' +import { DEMO_EFFORTS } from '../shared/demo-recording' +import { runDemoAgent } from './lib/demo-agent' const run = promisify(execFile) @@ -66,8 +76,8 @@ const MEDIA_BUCKET = 'skilld-demo-media' const MEDIA_TYPES: Readonly> = { '.jpg': 'image/jpeg', '.png': 'image/png', '.mp4': 'video/mp4' } const API = 'https://skilld.dev/api/v1/skills' const RECORD_TIMEOUT_MS = 20 * 60 * 1000 -/** Rendering a film takes far longer than writing a page. */ -const VIDEO_RECORD_TIMEOUT_MS = 60 * 60 * 1000 +/** Films and Codex workflows with separate review agents need a longer run window. */ +const LONG_RECORD_TIMEOUT_MS = 60 * 60 * 1000 /** Taller pages are cut here, so one shot stays a few hundred kilobytes. */ const MAX_SHOT_HEIGHT = 6000 @@ -167,6 +177,7 @@ interface DemoEntry { agent: string agentVersion: string model: string + effort?: DemoEffort skillCommit: string recordedAt: string pin?: number @@ -186,7 +197,7 @@ type OutputKind = 'page' | 'video' type Shell = { _tag: 'skilld-run' } | { _tag: 'sandboxed' } type Parsed - = | { _tag: 'record', owner: string, repo: string, name: string, makes: DemoMakes, prompt: string, output: string, kind: OutputKind, shell: Shell, seed: string | null, setup: string | null, skillPageOnly: boolean, resume: { dir: string, model: string } | null } + = | { _tag: 'record', owner: string, repo: string, name: string, makes: DemoMakes, prompt: string, output: string, kind: OutputKind, shell: Shell, seed: string | null, setup: string | null, skillPageOnly: boolean, agent: 'claude' | 'codex', model: string | null, effort: DemoEffort | null, allow: string[], source: string | null, resume: { dir: string, model: string } | null } | { _tag: 'reshoot', owner: string, repo: string, name: string } | { _tag: 'usage', message: string } @@ -201,6 +212,10 @@ function parseInput(argv: string[]): Parsed { 'seed': { type: 'string' }, 'resume': { type: 'string' }, 'model': { type: 'string' }, + 'agent': { type: 'string', default: 'claude' }, + 'effort': { type: 'string' }, + 'allow': { type: 'string', multiple: true }, + 'source': { type: 'string' }, 'setup': { type: 'string' }, 'reshoot': { type: 'boolean', default: false }, 'shell': { type: 'boolean', default: false }, @@ -226,22 +241,34 @@ function parseInput(argv: string[]): Parsed { return { _tag: 'usage', message: 'A --seed folder needs a --setup line that tells visitors what the folder held.' } if (values.resume && !values.model) return { _tag: 'usage', message: 'A --resume folder needs --model: the model the interrupted run used.' } + if (values.agent !== 'claude' && values.agent !== 'codex') + return { _tag: 'usage', message: 'Give --agent claude or codex.' } + const effort = DEMO_EFFORTS.find(value => value === values.effort) ?? null + if (values.effort && !effort) + return { _tag: 'usage', message: `Give --effort ${DEMO_EFFORTS.join(', ')}.` } + if (values.agent === 'codex' && (!values.model || !effort)) + return { _tag: 'usage', message: 'A Codex recording needs --model and --effort.' } + if (values.allow?.some(id => !/^[a-z][a-z0-9-]*$/.test(id))) + return { _tag: 'usage', message: 'Give each --allow behavior as one identifier.' } + if (values.source && !values.source.toLowerCase().startsWith(`github:${owner}/${repo}/`.toLowerCase())) + return { _tag: 'usage', message: 'The --source selector must name this Skill’s Repository.' } const resumed = values.resume && values.model ? { dir: resolve(values.resume), model: values.model } : null const shell: Shell = kind === 'video' || values.shell ? { _tag: 'sandboxed' } : { _tag: 'skilld-run' } - return { _tag: 'record', owner, repo, name, makes, prompt: values.prompt, output: values.output, kind, shell, seed: values.seed ? resolve(values.seed) : null, setup: values.setup ?? null, skillPageOnly: values['skill-page-only'], resume: resumed } + return { _tag: 'record', owner, repo, name, makes, prompt: values.prompt, output: values.output, kind, shell, seed: values.seed ? resolve(values.seed) : null, setup: values.setup ?? null, skillPageOnly: values['skill-page-only'], agent: values.agent, model: values.model ?? null, effort, allow: values.allow ?? [], source: values.source ?? null, resume: resumed } } interface SkillSource { commit: string authorName: string | null sourceUrl: string + skillPath: string | null } async function skillSource(owner: string, repo: string, name: string): Promise { const response = await fetch(`${API}/${owner}/${repo}/${name}`) if (!response.ok) throw new Error(`The API answered ${response.status} for ${owner}/${repo}/${name}. Is the Skill admitted?`) - const body = await response.json() as { sourceCommit?: unknown, authorName?: unknown, sourceUrl?: unknown } + const body = await response.json() as { sourceCommit?: unknown, authorName?: unknown, sourceUrl?: unknown, skillPath?: unknown } if (typeof body.sourceCommit !== 'string' || !/^[0-9a-f]{40}$/.test(body.sourceCommit)) throw new Error(`The API gave no source commit for ${owner}/${repo}/${name}.`) if (typeof body.sourceUrl !== 'string') @@ -250,6 +277,7 @@ async function skillSource(owner: string, repo: string, name: string): Promise { +async function pinnedRun(skillRef: string, allow: string[]): Promise<{ ref: string, commit: string }> { const dir = await mkdtemp(join(tmpdir(), 'skilld-pin-')) - const { stdout, stderr } = await run('npx', ['-y', 'skilld', 'run', skillRef], { cwd: dir, timeout: 120_000, maxBuffer: 32 * 1024 * 1024 }) + const { stdout, stderr } = await run('npx', ['-y', 'skilld', 'run', skillRef, ...allow.flatMap(id => ['--allow', id])], { cwd: dir, timeout: 120_000, maxBuffer: 32 * 1024 * 1024 }) const match = /skilld install '([^']+#commit:([0-9a-f]{40}))'/.exec(`${stdout}\n${stderr}`) if (!match?.[1] || !match[2]) throw new Error(`\`skilld run ${skillRef}\` printed no pinned commit, so the demo could not record what ran.`) return { ref: match[1], commit: match[2] } } -async function record(cwd: string, pinnedRef: string, prompt: string, kind: OutputKind, shell: Shell): Promise<{ model: string, version: string }> { - const instruction = `First run \`npx skilld run '${pinnedRef}'\` and follow the Skill it prints. Then do this task in the current folder:\n\n${prompt}` +async function record(cwd: string, pinnedRef: string, prompt: string, kind: OutputKind, shell: Shell, model: string | null, effort: DemoEffort | null, allow: string[]): Promise<{ model: string, version: string }> { + const instruction = `First run \`npx skilld run '${pinnedRef}'${allow.map(id => ` --allow ${id}`).join('')}\` and follow the Skill it prints. Then do this task in the current folder:\n\n${prompt}` // Without the sandboxed shell, Bash runs nothing beyond loading Skills. Either way it runs in the sandbox. // No user or project settings: they would load this machine's CLAUDE.md, Skills and allow rules, and a // global allow rule then reaches past --allowedTools, which only pre-approves. Only these four tools exist. @@ -319,7 +347,9 @@ async function record(cwd: string, pinnedRef: string, prompt: string, kind: Outp '--permission-mode', 'acceptEdits', ...access, - ], { cwd, env: agentEnv(process.env), timeout: kind === 'video' ? VIDEO_RECORD_TIMEOUT_MS : RECORD_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 }) + ...(model ? ['--model', model] : []), + ...(effort ? ['--effort', effort] : []), + ], { cwd, env: agentEnv(process.env), timeout: kind === 'video' ? LONG_RECORD_TIMEOUT_MS : RECORD_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 }) const result = JSON.parse(stdout) as { is_error?: boolean, result?: string, modelUsage?: Record } if (result.is_error) throw new Error(`Claude Code reported an error: ${result.result ?? 'no message'}`) @@ -328,8 +358,46 @@ async function record(cwd: string, pinnedRef: string, prompt: string, kind: Outp return { model: models[0] ?? 'unknown', version: versionOut.trim().split(' ')[0] ?? 'unknown' } } +/** Codex pins its model and effort explicitly and keeps its run events beside the output. */ +async function recordCodex(cwd: string, pinnedRef: string, prompt: string, model: string, effort: DemoEffort, allow: string[]): Promise<{ model: string, version: string }> { + const instruction = `First run \`npx skilld run '${pinnedRef}'${allow.map(id => ` --allow ${id}`).join('')}\` and follow the Skill it prints. Then do this task in the current folder:\n\n${prompt}` + const privateCodexPaths = ['auth.json', 'config.toml', 'sessions', 'log', 'memories', '*.sqlite*'].map(path => join(homedir(), '.codex', path)) + const denied = [...deniedPaths(), ...privateCodexPaths, '~/.claude', '~/.zshrc', '~/.zprofile', '~/.bashrc', '~/.profile'] + .map(path => path.startsWith('~/') ? join(homedir(), path.slice(2)) : path) + const filesystem = [ + '":root"="read"', + '":project_roots"="write"', + ...['.npm', '.cache/puppeteer', '.cache/ms-playwright'].map(path => `${JSON.stringify(join(homedir(), path))}="write"`), + ...denied.map(path => `${JSON.stringify(path)}="deny"`), + ].join(',') + const domains = [...SANDBOX_DOMAINS, 'localhost', '127.0.0.1'].map(host => `${JSON.stringify(host)}="allow"`).join(',') + const { stdout } = await runDemoAgent({ command: 'codex', args: [ + 'exec', + '--ignore-user-config', + '--skip-git-repo-check', + '--model', + model, + '-c', + `model_reasoning_effort="${effort}"`, + '-c', + 'default_permissions="demo"', + '-c', + `permissions.demo.filesystem={${filesystem}}`, + '-c', + `permissions.demo.network={enabled=true,mode="limited",allow_local_binding=true,domains={${domains}}}`, + '--json', + instruction, + ], cwd, env: agentEnv(process.env), timeout: LONG_RECORD_TIMEOUT_MS }) + const events = stdout.trim().split('\n').map(line => JSON.parse(line) as { type: string }) + if (!events.some(event => event.type === 'turn.completed')) + throw new Error(`Codex did not complete the recording. Check ${cwd}.`) + const { stdout: versionOut } = await run('codex', ['--version']) + return { model, version: versionOut.trim().split(' ')[1] ?? 'unknown' } +} + async function screenshot(page: string, dir: string, prompt: string): Promise { - const browser = await chromium.launch() + // Software WebGPU lets a recorded scene render on a headless machine without a physical GPU. + const browser = await chromium.launch({ args: ['--enable-unsafe-webgpu', '--use-angle=vulkan', '--use-vulkan=swiftshader', '--enable-features=Vulkan', '--disable-vulkan-surface'] }) const shots: Shot[] = [] for (const { viewport, width, height } of VIEWPORTS) { // A screenshot is a still, so the page draws its reduced motion state where it has one. @@ -551,7 +619,13 @@ async function main(): Promise { const { owner, repo, name, makes, prompt, output, kind, shell, seed, setup, skillPageOnly } = input const skillRef = `${owner}/${repo}/${name}` const source = await skillSource(owner, repo, name) - const pinned = await pinnedRun(skillRef) + if (input.source) { + const [, sourceOwner, sourceRepo] = new URL(source.sourceUrl).pathname.split('/') + const expected = source.skillPath ? `github:${sourceOwner}/${sourceRepo}/${dirname(source.skillPath)}#commit:${source.commit}` : null + if (!expected || input.source.toLowerCase() !== expected.toLowerCase()) + throw new Error('The --source selector must match the exact source path and commit in the registry.') + } + const pinned = await pinnedRun(input.source ?? skillRef, input.allow) const resumed = input.resume const cwd = resumed ? resumed.dir : await mkdtemp(join(tmpdir(), 'skilld-demo-')) @@ -559,8 +633,10 @@ async function main(): Promise { await cp(seed, cwd, { recursive: true }) console.log(`Recording ${skillRef} at ${pinned.commit.slice(0, 7)} in ${cwd}${shell._tag === 'sandboxed' ? ' with the sandboxed shell' : ''}`) const agent = resumed - ? { model: resumed.model, version: (await run('claude', ['--version'])).stdout.trim().split(' ')[0] ?? 'unknown' } - : await record(cwd, pinned.ref, prompt, kind, shell) + ? { model: resumed.model, version: (await run(input.agent, ['--version'])).stdout.trim().split(' ')[input.agent === 'codex' ? 1 : 0] ?? 'unknown' } + : input.agent === 'codex' && input.model && input.effort + ? await recordCodex(cwd, pinned.ref, prompt, input.model, input.effort, input.allow) + : await record(cwd, pinned.ref, prompt, kind, shell, input.model, input.effort, input.allow) const produced = await findOutput(cwd, output) if (!produced) @@ -587,9 +663,10 @@ async function main(): Promise { prompt, ...(setup ? { setup } : {}), ...(skillPageOnly ? { skillPageOnly: true as const } : {}), - agent: 'Claude Code', + agent: input.agent === 'codex' ? 'Codex' : 'Claude Code', agentVersion: agent.version, model: agent.model, + ...(input.effort ? { effort: input.effort } : {}), skillCommit: pinned.commit, recordedAt: new Date().toISOString().slice(0, 10), ...media, diff --git a/shared/demo-recording.ts b/shared/demo-recording.ts index 33f64bad..95a2f839 100644 --- a/shared/demo-recording.ts +++ b/shared/demo-recording.ts @@ -5,6 +5,15 @@ const AGENT_ICONS: Readonly> = { 'Claude Code': 'i-simple-icons-claude', + 'Codex': 'i-simple-icons-openai', +} + +export const DEMO_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const +export type DemoEffort = typeof DEMO_EFFORTS[number] + +/** Older recordings carry no effort evidence. */ +export function demoRecordingLabel(model: string, effort: DemoEffort | null): string { + return `${demoModelLabel(model)}${effort ? `, ${effort} effort` : ''}` } /** The Agent's logo, or a generic one for an Agent this table does not know. */ diff --git a/test/fixtures/demo-agent-stdin.ts b/test/fixtures/demo-agent-stdin.ts new file mode 100644 index 00000000..280b8016 --- /dev/null +++ b/test/fixtures/demo-agent-stdin.ts @@ -0,0 +1,4 @@ +import process from 'node:process' + +process.stdin.resume() +process.stdin.on('end', () => process.stdout.write('input closed')) diff --git a/test/unit/demo-agent.test.ts b/test/unit/demo-agent.test.ts new file mode 100644 index 00000000..a616dbdd --- /dev/null +++ b/test/unit/demo-agent.test.ts @@ -0,0 +1,17 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { runDemoAgent } from '../../scripts/lib/demo-agent' + +describe('runDemoAgent', () => { + it('closes stdin so an argument-based prompt can start', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'demo-agent-test-')) + const fixture = fileURLToPath(new URL('../fixtures/demo-agent-stdin.ts', import.meta.url)) + const result = await runDemoAgent({ command: process.execPath, args: [fixture], cwd, env: process.env, timeout: 2000 }) + .finally(() => rm(cwd, { recursive: true })) + expect(result.stdout).toBe('input closed') + }) +}) diff --git a/test/unit/demo-recording.test.ts b/test/unit/demo-recording.test.ts index 023843d2..66dbe494 100644 --- a/test/unit/demo-recording.test.ts +++ b/test/unit/demo-recording.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'vitest' -import { demoAgentIcon, demoModelLabel } from '../../shared/demo-recording' +import { demoAgentIcon, demoModelLabel, demoRecordingLabel } from '../../shared/demo-recording' + +describe('demoRecordingLabel', () => { + it('shows the recorded effort with the model', () => { + expect(demoRecordingLabel('gpt-6.1-sol', 'medium')).toBe('gpt-6.1-sol, medium effort') + }) + + it('does not invent effort for an older recording', () => { + expect(demoRecordingLabel('claude-opus-5-5', null)).toBe('Opus 5.5') + }) +}) describe('demoModelLabel', () => { it('reads a Claude model id as its family and version', () => { diff --git a/test/unit/skill-demos.test.ts b/test/unit/skill-demos.test.ts index 177d7f79..de261a13 100644 --- a/test/unit/skill-demos.test.ts +++ b/test/unit/skill-demos.test.ts @@ -22,6 +22,10 @@ function demo(overrides: Partial = {}): SkillDemoRecord { } describe('presentSkillDemo', () => { + it('keeps the recorded effort for the display', () => { + expect(presentSkillDemo(demo({ effort: 'medium' }), null).effort).toBe('medium') + expect(presentSkillDemo(demo(), null).effort).toBeNull() + }) it('marks a demo outdated once the Skill moves past the recorded commit', () => { expect(presentSkillDemo(demo(), 'a'.repeat(40)).outdated).toBe(true) expect(presentSkillDemo(demo(), COMMIT).outdated).toBe(false)