[STG-2275] feat(cli): default screenshot to file output (--base64 for legacy stdout)#2246
[STG-2275] feat(cli): default screenshot to file output (--base64 for legacy stdout)#2246shrey150 wants to merge 4 commits into
Conversation
…gacy stdout Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 81159c5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 0 packagesWhen changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
1 issue found across 2 files
Confidence score: 3/5
- In
packages/cli/src/commands/screenshot.ts, the default collision handling does a non-atomic existence check before write, so concurrent CLI runs can still pick the same path and overwrite each other’s screenshot output; switch to an atomic create/unique-name strategy (or include a guaranteed-unique suffix) before merging to avoid intermittent data loss.
Architecture diagram
sequenceDiagram
participant User as User/Agent
participant CLI as browse CLI
participant FS as File System
participant Driver as Driver Handler (runtime.ts)
participant Browser as Browser Process
Note over User,Browser: Screenshot command flow (new default path)
User->>CLI: browse screenshot [--type png|jpeg] [--path ...|--base64]
alt Bare invocation (no --path, no --base64)
CLI->>CLI: defaultScreenshotPath(type)
CLI->>CLI: generate timestamp "screenshot-<yyyymmdd-hhmmss>.<ext>"
CLI->>FS: check exists? (collision counter loop)
FS-->>CLI: not found / counter found
CLI->>CLI: resolve absolute path from cwd
CLI->>Driver: screenshot({ path: absolutePath, type, ... })
Driver->>Browser: capture to buffer
Browser-->>Driver: PNG/JPEG buffer
Driver->>FS: write file at path
FS-->>Driver: written
Driver-->>CLI: { saved: "absolutePath" }
CLI-->>User: { "saved": "<absolutePath>" }
else --base64 flag (legacy stdout contract)
CLI->>Driver: screenshot({ base64: true, type, ... })
Driver->>Browser: capture to buffer
Browser-->>Driver: PNG/JPEG buffer
Driver->>Driver: encode base64
Driver-->>CLI: { base64: "..." }
CLI-->>User: { "base64": "..." }
else --path flag (explicit path, unchanged)
CLI->>CLI: use provided path directly
CLI->>Driver: screenshot({ path: explicitPath, type, ... })
Driver->>Browser: capture to buffer
Browser-->>Driver: PNG/JPEG buffer
Driver->>FS: write file at path
FS-->>Driver: written
Driver-->>CLI: { saved: "explicitPath" }
CLI-->>User: { "saved": "<explicitPath>" }
end
Note over CLI: --base64 and --path are mutually exclusive (oclif validation)
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…current overwrite Replace the existsSync collision check with an exclusive-create (wx) reservation so two concurrent bare invocations in the same second can never claim the same default filename. Clean up the empty placeholder if the screenshot command fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d skill Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w default) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
…ed browse skill (#2245) ## Summary Linear: https://linear.app/browserbase/issue/STG-2274/add-death-loop-prevention-guidance-to-bundled-browse-skill Adds death-loop prevention guidance to the bundled browse skill (`packages/cli/skills/browse/SKILL.md`) — the skill agents install via `browse skills install`. ## Impact if merged Most agent traffic reaches the CLI through this bundled skill — it is the agent interface. The current text has no 401/invalid-key playbook and no anti-retry rule, the two behaviors behind the death-loop population: 71 installs generating 92.3% of all CLI telemetry (~5.5M events/30d) retrying `get`/`screenshot`, including ~375k events from tagged claude-code/codex agents — the core ICP. Docs-only, zero runtime risk; pairs with the in-flight driver-error remediation PR (the code-side fix for the same loops). ## Changes 1. **401 playbook** (Troubleshooting): a set `BROWSERBASE_API_KEY` makes `browse` default to remote mode (`src/lib/driver/remote.ts` `defaultRemoteTarget`), so an invalid key turns every driver command into a 401. New entry tells agents to fix the key at https://browserbase.com/settings, unset it, or pass `--local`. 2. **Anti-retry rule** (Best Practices #9 + Troubleshooting): never retry a failing command unchanged — if the same command fails twice with the same error, stop, run `browse doctor --json`, then change approach. Init failures are cached in the daemon for several seconds (`INIT_FAILURE_RETRY_MS` in `session-manager.ts`), so instant retries return identical errors. ~~3. Screenshot guidance~~ — moved to #2246 per review, where it documents the new file-by-default behavior that PR ships (reverted here in cb6e0c6). No changeset: docs/prompt-text only — ships with the next regular `browse` release, no version bump needed. ## E2E Test Matrix | Command / flow | Observed output | Confidence / sufficiency | | --- | --- | --- | | `<local build>`: `pnpm turbo run build --filter=browse...` then `node bin/run.js skills install` | Installed from `<worktree>/packages/cli/skills/browse` to `~/.agents/skills/browse` across 71 agents (universal + symlinked) | Proves the bundled skill file under review is exactly what `skills install` ships | | `grep -n "401 Unauthorized\|Never retry a failing command\|stop retrying" ~/.agents/skills/browse/SKILL.md` | 3 hits: Best Practices #9 (anti-retry), 401 troubleshooting entry, Troubleshooting anti-retry rule | Proves both remaining additions are present in the installed copy agents actually read (screenshot row moved to #2246) | | `npx vitest run tests/skills.test.ts tests/skills-install.test.ts` | 22/22 passed | Skill packaging/install tests cover the edited file's frontmatter and install path | | `npx vitest run` (full packages/cli suite) | 213/213 passed, 15 files | Supporting — proves no CLI breakage; this PR touches no TS | | `npx prettier --check packages/cli/skills/browse/SKILL.md` | "All matched files use Prettier code style!" | Formatting matches repo style | 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Add a 401 troubleshooting playbook and an anti-retry rule to the bundled `browse` skill docs to prevent agent death-loops and reduce noisy telemetry. Addresses Linear STG-2274; docs-only change in `packages/cli/skills/browse/SKILL.md`. - **Docs** - 401 Unauthorized playbook for invalid `BROWSERBASE_API_KEY` (fix the key, unset it, or use `--local`). - Anti-retry rule: never repeat an unchanged failing command. If it fails twice, stop, run `browse doctor --json`, then change approach (fix key, switch `--local`/`--remote`, or `browse stop --force`). Init failures can be cached for a few seconds, so instant retries re-fail. <sup>Written for commit cb6e0c6. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/browserbase/stagehand/pull/2245?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- bump changeset to minor (bare-invocation stdout contract change) - clarify default filetype (png, or jpeg with --type) in --path help - extract getDefaultPathFromFlags - cap the reserve loop at 1000 attempts so it always terminates - comment the wx/O_EXCL reservation + closeSync, guard removeIfEmpty with isFile() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // Removes the reserved placeholder when the screenshot failed before the driver | ||
| // wrote to it. `path` is always a file we created via openSync above, so the | ||
| // isFile guard is just defensive against an unexpected directory/symlink. | ||
| function removeIfEmpty(path: string): void { |
There was a problem hiding this comment.
this feels like there's a utility function for it
There was a problem hiding this comment.
There isn't a stdlib or repo utility for "delete a file if it's empty" — it's inherently stat + unlink, and we don't depend on fs-extra (and even that has no unlink-if-empty helper). So no clean util to lean on here.
But the real question is whether this function needs to exist at all, and it only does as the cleanup half of the atomic wx reservation: we pre-create a 0-byte placeholder to win the filename race (the concurrent-overwrite issue cubic flagged), so if the screenshot then fails before the driver writes, we'd leave a stray empty screenshot-<ts>.png behind. removeIfEmpty deletes that placeholder.
Two ways to simplify if the complexity isn't worth it:
- Drop
removeIfEmptyand accept that a failed screenshot leaves a 0-byte file in cwd (rare, minor litter). −7 lines. - Drop the whole reservation (placeholder + loop + cleanup, ~20 lines) by making the name collision-proof without touching disk — e.g. a short random suffix
screenshot-<ts>-<rand>.png. Removes the race entirely, but the filename is less clean.
I'd lean toward keeping it as-is (zero litter, clean names, and it's tested), but happy to take either simplification — your call.
Summary
Bare
browse screenshotnow writes a file by default —screenshot-<yyyymmdd-hhmmss>.<type>in the current directory, never overwriting (atomic collision counter) — and prints the small{ "saved": "<path>" }JSON. A new--base64flag preserves the legacy stdout contract ({ "base64": "..." }); it is mutually exclusive with--path. Explicit--pathbehavior is unchanged.Linear: STG-2275
Impact if merged
screenshot is one of the two commands in the runaway agent retry loops (the loop population generates 92.3% of all CLI telemetry) and is used by 1,337 users/30d (89.9% success last-7d). Every bare invocation today prints ~22KB of base64 JSON directly into the calling agent's context window — a per-call token tax on exactly the ICP population (claude-code/codex agents driving the CLI; agent-tagged usage is 90%-successful and growing). Defaulting to a file write makes the common case agent-safe at zero cost to
--pathusers;--base64preserves the old contract for scripts. Stdout contract change for bare invocations is called out in the changeset with a--base64migration note (patch bump per repo convention).Implementation notes
browse screenshotnow prints{ "saved": "<path>" }instead of{ "base64": "..." }. Migration: pass--base64to restore the old output.runtime.ts) already supported both branches ({ saved }when a path is given,{ base64 }otherwise), so the change is confined tosrc/commands/screenshot.ts.--type(.jpegvs.png) and resolves against the invoking shell's cwd (absolute path passed to the driver so a daemon with a different cwd can't misplace the file).openSync(path, "wx")), advancing a-2,-3, ... counter onEEXIST— concurrent same-second invocations can never claim the same file (addresses cubic's race-condition review). If the command fails afterward, the empty placeholder is removed best-effort.--base64is mutually exclusive with--pathvia oclif'sexclusiveoption.browsepatch (per review) — the bare-invocation stdout contract change is called out in the changeset body with the--base64migration note.skills/browse/SKILL.mdscreenshot snippet updated in this PR (moved from [STG-2274] docs(cli): add 401 playbook + anti-retry guidance to bundled browse skill #2245 per review) to document the new default: bare saves a file,--pathchooses it,--base64is the legacy stdout form.E2E Test Matrix
All rows ran against the local build (
pnpm buildinpackages/cli, invoked asnode bin/run.js) from a<scratch dir>.node bin/run.js open https://example.com{ "title": "Example Domain", "url": "https://example.com/", ... }, exit 0node bin/run.js screenshot(bare){ "saved": "<scratch dir>/screenshot-20260612-123732.png" }, exit 0;ls -lashows the file at 15,988 bytes; stdout is the small JSON onlynode bin/run.js screenshot --base64 | head -c 200{ "base64": "iVBORw0KGgoAAAANSUhEUgAABQgAAALH..."(PNG magic in base64), exit 0--base64.node bin/run.js screenshot --path /tmp/custom.png{ "saved": "/tmp/custom.png" }, exit 0; file exists at 15,238 bytes--pathbehavior is unchanged.&+wait){ "saved": ".../screenshot-20260612-123741.png" }and{ "saved": ".../screenshot-20260612-123741-2.png" }; both files present and non-empty (4,202 bytes each)node bin/run.js screenshot --cdp http://127.0.0.1:1(forced failure)TypeError: fetch failed, nonzero exit; directory left empty — no placeholder filescreenshot-*.pngplaceholders behind.node bin/run.js screenshot --base64 --path /tmp/x.pngError: --path=/tmp/x.png cannot also be provided when using --base64, exit 2node bin/run.js stop{ "stopped": true, "session": "default" }, exit 0pnpm test(packages/cli)Test Files 15 passed (15), Tests 213 passed (213)— re-run after the race fix--helpsurface test).pnpm lint(packages/cli)🤖 Generated with Claude Code