Skip to content

[STG-2275] feat(cli): default screenshot to file output (--base64 for legacy stdout)#2246

Open
shrey150 wants to merge 4 commits into
mainfrom
shrey/screenshot-default-file
Open

[STG-2275] feat(cli): default screenshot to file output (--base64 for legacy stdout)#2246
shrey150 wants to merge 4 commits into
mainfrom
shrey/screenshot-default-file

Conversation

@shrey150

@shrey150 shrey150 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Bare browse screenshot now 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 --base64 flag preserves the legacy stdout contract ({ "base64": "..." }); it is mutually exclusive with --path. Explicit --path behavior 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 --path users; --base64 preserves the old contract for scripts. Stdout contract change for bare invocations is called out in the changeset with a --base64 migration note (patch bump per repo convention).

Implementation notes

  • Breaking change (stdout contract): bare browse screenshot now prints { "saved": "<path>" } instead of { "base64": "..." }. Migration: pass --base64 to restore the old output.
  • Command-layer only: the driver handler (runtime.ts) already supported both branches ({ saved } when a path is given, { base64 } otherwise), so the change is confined to src/commands/screenshot.ts.
  • Default filename respects --type (.jpeg vs .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).
  • The default filename is atomically reserved via exclusive create (openSync(path, "wx")), advancing a -2, -3, ... counter on EEXIST — 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.
  • --base64 is mutually exclusive with --path via oclif's exclusive option.
  • Changeset: browse patch (per review) — the bare-invocation stdout contract change is called out in the changeset body with the --base64 migration note.
  • Bundled skill doc: skills/browse/SKILL.md screenshot 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, --path chooses it, --base64 is the legacy stdout form.

E2E Test Matrix

All rows ran against the local build (pnpm build in packages/cli, invoked as node bin/run.js) from a <scratch dir>.

Command / flow Observed output Confidence / sufficiency
node bin/run.js open https://example.com { "title": "Example Domain", "url": "https://example.com/", ... }, exit 0 Session setup for the runs below; proves the local build is functional end-to-end.
node bin/run.js screenshot (bare) { "saved": "<scratch dir>/screenshot-20260612-123732.png" }, exit 0; ls -la shows the file at 15,988 bytes; stdout is the small JSON only Proves the new default: file written to cwd with timestamped name, no base64 on stdout.
node bin/run.js screenshot --base64 | head -c 200 { "base64": "iVBORw0KGgoAAAANSUhEUgAABQgAAALH..." (PNG magic in base64), exit 0 Proves the legacy stdout contract is fully preserved behind --base64.
node bin/run.js screenshot --path /tmp/custom.png { "saved": "/tmp/custom.png" }, exit 0; file exists at 15,238 bytes Proves explicit --path behavior is unchanged.
Two concurrent bare runs (same second, shell & + wait) { "saved": ".../screenshot-20260612-123741.png" } and { "saved": ".../screenshot-20260612-123741-2.png" }; both files present and non-empty (4,202 bytes each) Proves the atomic no-overwrite reservation under true same-second concurrency — the exact race cubic flagged.
node bin/run.js screenshot --cdp http://127.0.0.1:1 (forced failure) TypeError: fetch failed, nonzero exit; directory left empty — no placeholder file Proves failed runs do not leave empty screenshot-*.png placeholders behind.
node bin/run.js screenshot --base64 --path /tmp/x.png Error: --path=/tmp/x.png cannot also be provided when using --base64, exit 2 Proves the oclif mutual exclusion works.
node bin/run.js stop { "stopped": true, "session": "default" }, exit 0 Clean session teardown.
pnpm test (packages/cli) Test Files 15 passed (15), Tests 213 passed (213) — re-run after the race fix Supporting: no regressions in the existing CLI suite (includes the screenshot --help surface test).
pnpm lint (packages/cli) tsc/eslint/prettier clean Supporting only.

🤖 Generated with Claude Code

…gacy stdout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 81159c5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When 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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)
Loading

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread packages/cli/src/commands/screenshot.ts Outdated
…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>
Comment thread .changeset/screenshot-default-file.md
…d skill

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
shrey150 added a commit that referenced this pull request Jun 12, 2026
…w default)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/cli/src/commands/screenshot.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread .changeset/screenshot-default-file.md Outdated
Comment thread packages/cli/skills/browse/SKILL.md
Comment thread packages/cli/src/commands/screenshot.ts Outdated
Comment thread packages/cli/src/commands/screenshot.ts Outdated
Comment thread packages/cli/src/commands/screenshot.ts Outdated
Comment thread packages/cli/src/commands/screenshot.ts
Comment thread packages/cli/src/commands/screenshot.ts Outdated
Comment thread packages/cli/src/commands/screenshot.ts Outdated
shrey150 added a commit that referenced this pull request Jun 16, 2026
…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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this feels like there's a utility function for it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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:

  1. Drop removeIfEmpty and accept that a failed screenshot leaves a 0-byte file in cwd (rare, minor litter). −7 lines.
  2. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants