
Launch
- 224 installs
- 188k repo stars
- Updated July 28, 2026
- microsoft/vscode
Launch Code OSS (VS Code from sources) into an isolated throwaway profile with unique debug ports so you can drive it with @playwright/cli AND attach a Node debugger via dap-cli in the same session.
About
Launch Code OSS (VS Code from sources) into an isolated throwaway profile with unique debug ports so you can drive it with @playwright/cli AND attach a Node debugger via dap-cli in the same session. Use when working on VS Code itself and you want to interact with the running workbench, automate chat or UI flows, test UI features, take screenshots, set breakpoints in the renderer / extension host / main process, or combine UI driving with debugging. You're working on VS Code itself and you want to:
- # Code OSS Dev - Launch + Debug
- You're working on VS Code itself and you want to:
- Launch a Code OSS build from sources that is **already signed in** (Copilot, GitHub, etc.) so chat / agent flows work en
- Drive it with `@playwright/cli` over CDP (UI automation).
- Optionally attach a debugger via **dap-cli** to set breakpoints in the renderer, extension host, or main process.
Launch by the numbers
- 224 all-time installs (skills.sh)
- Ranked #770 of 2,184 Testing & QA skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
launch capabilities & compatibility
- Capabilities
- # code oss dev launch + debug · you're working on vs code itself and you want to · launch a code oss build from sources that is **a · drive it with `@playwright/cli` over cdp (ui aut
- Use cases
- documentation
What launch says it does
Launch Code OSS (VS Code from sources) into an isolated throwaway profile with unique debug ports so you can drive it with @playwright/cli AND attach a Node debugger via dap-cli in the same session. U
npx skills add https://github.com/microsoft/vscode --skill launchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 224 |
|---|---|
| repo stars | ★ 188k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 28, 2026 |
| Repository | microsoft/vscode ↗ |
How do I apply launch using the workflow in its SKILL.md?
Launch Code OSS (VS Code from sources) into an isolated throwaway profile with unique debug ports so you can drive it with @playwright/cli AND attach a Node debugger via dap-cli in the sa...
Who is it for?
Developers following the launch skill for the tasks it documents.
Skip if: Tasks outside the launch scope described in SKILL.md.
When should I use this skill?
User mentions launch or related triggers from the skill description.
What you get
Working launch setup aligned with the documented patterns and constraints.
Files
Code OSS Dev - Launch + Debug
You're working on VS Code itself and you want to:
1. Launch a Code OSS build from sources that is already signed in (Copilot, GitHub, etc.) so chat / agent flows work end-to-end. 2. Drive it with @playwright/cli over CDP (UI automation). 3. Optionally attach a debugger via dap-cli to set breakpoints in the renderer, extension host, or main process. 4. Run multiple instances at once without port conflicts.
This skill provides a launcher that clones an authenticated user-data-dir to a throwaway temp folder, picks free ports for every debug surface, and prints them as JSON so you can pick them up programmatically.
The clone is slim: workspace storage, browser caches, file history, cached VSIX backups, and old logs are excluded by default. Auth tokens themselves live in the OS keychain (shared automatically) plus small files inside User/globalStorage - both of which are preserved.
Prerequisites
- macOS or Linux. The launcher is a bash script and depends on
rsync,curl,nohup, and Node onPATH. The example caller snippets below also usejq(parse the JSON output) andlsof(kill-by-port fallback) — install those if you plan to use them, but the launcher itself does not require them. - A VS Code checkout with
node_modules/installed (npm installif missing — do not symlink from a sibling worktree; that breaks builds in subtle ways). - A VS Code checkout with sources built. Run
npm run compileonce (one-shot) ornpm run watchfor incremental rebuilds. Both build the full client and all built-in extensions underextensions/. You must build the full product to run successfully, building just the client is not enough. - An authenticated Code OSS profile to seed from. By default the launcher uses
~/.vscode-oss-dev, which is the user-data-dir the repo'slaunch.jsonconfigs use - if the user has ever signed in to Copilot in a dev build, this should work. Only pass--source-user-data-dir <path>(or set$CODE_OSS_DEV_AUTHED_USER_DATA_DIR) when you specifically want to seed from a different profile (e.g. your regular~/Library/Application Support/Codeinstall). - If Code OSS launches and needs a sign-in, don't give up! Use the questions tool to ask the user to sign in.
@playwright/cliavailable (it's a devDependency in the vscode repo -npm installthen usenpx @playwright/cli).- For debugger work:
dap-clionPATH. If debugger support would be useful but thedap-cliskill is not present, prompt the user to install it from https://github.com/roblourens/dap-cli. - CSS selectors are internal implementation details. If a selector-based
evalstops working, take a freshsnapshot, inspect the current DOM, and update the selector rather than assuming an old one still applies.
The launcher copies the source profile to a temp dir and never mutates the original. Each launch gets its own isolated--user-data-dirand--extensions-dir.
The launcher always setsfiles.simpleDialog.enable: truein the launched profile'sUser/settings.json. This is required for automation: VS Code's native OS file dialogs cannot be driven via@playwright/cliover CDP and are completely unreachable over SSH on headless macOS. The simple (quick-input) dialog can be navigated withpressand clipboard paste. The override is per-launch and only affects throwaway profiles.
Launch
The launcher script lives next to this SKILL.md at scripts/launch.sh. Resolve it relative to wherever this skill file is installed - do not hardcode an absolute path.
# LAUNCH=<dir-of-this-SKILL.md>/scripts/launch.sh
"$LAUNCH" # default: workbench
"$LAUNCH" --agents # Agents window
"$LAUNCH" -- <workspace-path> # forward extra args to code.sh
"$LAUNCH" --source-user-data-dir <path> # pick a specific authed profile
"$LAUNCH" --repo <vscode-repo-root> # if not run from the repo
"$LAUNCH" --clone-extensions # start with a copy of the source extensions/ (~few seconds)
"$LAUNCH" --full # skip slim excludes; copy everythingWhat gets copied (slim mode, the default)
The exclude list mirrors the one used by VS Code's own perf-test skill (.github/skills/auto-perf-optimize), which is known to keep Copilot auth and language-model availability working. Specifically WebStorage/, Service Worker/, Local Storage/, Cookies, Network Persistent State, TransportSecurity, Trust Tokens, Preferences, machineid, and the entire User/globalStorage/ (which holds state.vscdb - where extension SecretStorage blobs live, encrypted with the OS keychain key) are all preserved. Auth tokens themselves stay in the OS keychain, which is per-user, so they follow automatically.
Excluded (transient, regenerable, or known-not-needed):
User/workspaceStorage/- per-workspace state, including stored chat sessions (often multi-GB)User/History/- local file edit historyCachedExtensionVSIXs- backup VSIXs (hundreds of MB)logs- Chromium caches:
Cache,Code Cache,CachedData,GPUCache,ShaderCache,Dawn*Cache,component_crx_cache Backups,blob_storage,BrowserMetrics,Crashpad,Session StorageSingleton*,*.lock,*.sock(would conflict with the source instance)
extensions/ defaults to a fresh empty directory - fastest and conflict-free, but the launched instance starts with no third-party extensions installed. Pass --clone-extensions to copy the source extensions dir into the temp profile so the new instance is independent of the source. Pass --full to skip all excludes if you suspect the slim copy is missing something you need.
Why never share the source `extensions/` dir directly? The extension management service writes a shared.obsoletefile; two concurrent writers crash each other's shared background process. The launcher always uses an isolated extensions dir for the same reason it uses--shared-data-dir(see below).
If the launched window says "language model unavailable" or otherwise looks unauthed, ask the user to sign in.
The script runs pre-launch (electron download, compile-if-missing, built-in extensions) in the foreground, then starts Code OSS detached and blocks until the renderer's CDP endpoint is responding (up to ~90s) before printing the JSON line on stdout. If anything fails — preLaunch errors, code.sh exits early, CDP never opens — the script exits non-zero and dumps the relevant log tail to stderr.
{"pid":12345,"cdpPort":53111,"extHostPort":53112,"mainPort":53113,"agentHostPort":53114,"userDataDir":".../user-data","extensionsDir":".../extensions","sharedDataDir":".../shared-data","runDir":"...","logFile":".../code.log","repo":"...","agents":false}Capture it with jq — no retry loop needed, CDP is already up when the JSON is printed:
INFO=$("$LAUNCH" | tail -n1)
CDP=$(jq -r .cdpPort <<<"$INFO")
EXT=$(jq -r .extHostPort <<<"$INFO")
MAIN=$(jq -r .mainPort <<<"$INFO")
AGENT=$(jq -r .agentHostPort <<<"$INFO")
LOG=$(jq -r .logFile <<<"$INFO")
PID=$(jq -r .pid <<<"$INFO")What each port is for
| Port | Process | Use with |
|---|---|---|
cdpPort (--remote-debugging-port) | Renderer (the workbench window) | @playwright/cli over CDP, also Chrome DevTools |
extHostPort (--inspect-extensions) | Extension host (Node) | dap-cli (Node inspector protocol) |
mainPort (--inspect) | Electron main process (Node) | dap-cli (Node inspector protocol) |
agentHostPort (--inspect-agenthost) | Agent host process (Node) | dap-cli (Node inspector protocol) |
Drive the UI with @playwright/cli
Use the dynamic cdpPort from the launch JSON. The normal loop is: attach, confirm the target, snapshot, interact, then re-snapshot after meaningful UI changes.
Always pick a unique `PW_SESSION` name and pass it as `-s=$PW_SESSION` on everynpx @playwright/cli ...call. The CLI is backed by a persistent daemon (cliDaemon.js) keyed by session name; if two shells both omit-s=, they share the implicit"default"session and the most-recently-attached CDP "wins" for every subsequent command from either shell. The launch skill is built around isolation (per-instance UDD, ports, shared-data-dir), and this pattern keeps that isolation intact at the Playwright-driving layer too. A note on the alternative `PLAYWRIGHT_CLI_SESSION` env var: it's documented in the package README and works correctly foropen-style workflows, but it interacts poorly withattach --cdp=...(the daemon ends up with both--cdp=...and--endpoint=<env-value>, and the latter wins, causing aconnect ENOENTfailure). Confirmed against@playwright/cli@0.1.13. Explicit-s=NAMEworks in all modes.
# At the top of your script / subagent prompt:
PW_SESSION="my-uniq-$$" # any unique string; $$ is fine for one shell per agent
# launch.sh blocks until CDP is ready, so a single attach is enough.
npx @playwright/cli -s=$PW_SESSION attach --cdp=http://127.0.0.1:$CDP
npx @playwright/cli -s=$PW_SESSION tab-list
npx @playwright/cli -s=$PW_SESSION snapshotAfter attach, later @playwright/cli commands keep using the connected app until you close or reattach — as long as you keep passing the same -s=$PW_SESSION.
Selecting the right Electron target
Electron apps can expose multiple windows or webviews. If tab-list shows about:blank, a webview, or otherwise the wrong target, switch targets before interacting:
npx @playwright/cli -s=$PW_SESSION tab-list
npx @playwright/cli -s=$PW_SESSION tab-select 2
npx @playwright/cli -s=$PW_SESSION snapshotIf a target looks stale after relaunching, run npx @playwright/cli -s=$PW_SESSION close, attach again with $CDP, and re-check tab-list.
Focusing the chat input (works on Code OSS, including the Agents window)
# macOS
npx @playwright/cli -s=$PW_SESSION press Control+Meta+i
# Linux / Windows
npx @playwright/cli -s=$PW_SESSION press Control+Alt+iTyping into Monaco (chat input, editors)
fill and type silently fail on Code OSS — Monaco's native-edit-context element doesn't react to Playwright's default input pipeline. Use one of these alternatives:
- `scripts/monaco-paste.sh` helper (recommended — fast, no system clipboard, parallel-safe). Reads text from a positional arg or stdin and dispatches a
ClipboardEvent('paste')with aDataTransferpayload into the focused chat-input Monaco editor. Honors--session NAMEor$PW_SESSIONenv so it stays inside the same-s=session as everything else.
LAUNCH_DIR=<dir-of-this-SKILL.md> # the same dir that holds scripts/launch.sh
PASTE="$LAUNCH_DIR/scripts/monaco-paste.sh"
export PW_SESSION # helper reads this env var
# Send a prompt:
npx @playwright/cli -s=$PW_SESSION press Control+Meta+i # focus chat input
"$PASTE" 'Please run `pwd && ls` using your terminal tool.'
npx @playwright/cli -s=$PW_SESSION press Enter
# Long / arbitrary text via stdin (avoids any shell-quoting headaches):
printf 'multi-line prompt\nwith backticks `x`\nand emoji 🎉' | "$PASTE"
# Append without clearing:
"$PASTE" --append " continued text"
# Skip the read-back check (useful when intentionally pasting more than the
# chat input's ~600-character soft cap):
"$PASTE" --no-verify "...long text..."
# Or pass the session explicitly per call (if you don't want to export PW_SESSION):
"$PASTE" --session "$PW_SESSION" "..."The helper prints a single JSON line on stdout: {ok, actualLength, expectedLength, viewLineCount, firstViewLine, error?}. Exit 0 on success, 1 on verify failure, 2 on argument errors. Tested reliable across 20+ sequential pastes including unicode (中文), emoji (🎉), backticks, ampersands, embedded quotes, and newlines.
Why a helper script and not just docs: the inline recipe involves a multi-line node -e heredoc with embedded JS template literals, which is exactly the kind of code that gets miscopied. There are also three non-obvious correctness traps the helper handles internally: 1. Monaco's native-edit-context doesn't react to fill or type, only to actual paste events (or per-key press). 2. Monaco renders ASCII spaces as U+00A0 (NBSP) in the view-line DOM, so verification has to normalize before comparing. 3. Monaco updates its DOM asynchronously after a paste event — a synchronous read-back inside the same eval returns stale state. The helper waits two requestAnimationFrame ticks before reading.
- Per-key `press` (universal but slow — each press is a separate CLI invocation with Node startup cost):
npx @playwright/cli -s=$PW_SESSION press H
npx @playwright/cli -s=$PW_SESSION press i
npx @playwright/cli -s=$PW_SESSION press Enter- Clipboard paste via `pbcopy` (fast on macOS, but `NSPasteboard` is system-wide so any concurrent shell that touches the pasteboard will collide). Only use when nothing else on the machine is using the clipboard for the duration of the paste.
printf '%s' "Your prompt here" | pbcopy
npx @playwright/cli -s=$PW_SESSION press Control+Meta+i
npx @playwright/cli -s=$PW_SESSION press Meta+v
npx @playwright/cli -s=$PW_SESSION press EnterThe focus shortcut should leave document.activeElement on VS Code's native-edit-context editing surface. That is a useful sanity check when key presses appear to do nothing.
Parallel multi-instance pattern
Because the launch skill is built around isolation, the natural workload is many agents on one machine, each driving their own Code OSS. The pattern boils down to giving each agent a unique PW_SESSION and passing it everywhere:
# In agent A's shell:
PW_SESSION="agent-A-$$"
INFO=$("$LAUNCH" --agents -- --use-mock-keychain | tail -n1)
CDP=$(jq -r .cdpPort <<<"$INFO")
npx @playwright/cli -s=$PW_SESSION attach --cdp=http://127.0.0.1:$CDP
"$PASTE" "prompt for A" # helper picks up $PW_SESSION
# In agent B's shell (running concurrently):
PW_SESSION="agent-B-$$"
INFO=$("$LAUNCH" --agents -- --use-mock-keychain | tail -n1)
CDP=$(jq -r .cdpPort <<<"$INFO")
npx @playwright/cli -s=$PW_SESSION attach --cdp=http://127.0.0.1:$CDP
"$PASTE" "prompt for B"Each agent gets its own cliDaemon bound to its own CDP, so the pastes / clicks / snapshots don't cross-contaminate. Verified live with two concurrent instances. macOS Mach-ports caveat: on macOS, beyond ~2–3 concurrent Code OSS instances Crashpad's exception handler tends to die with mach_port_request_notification: invalid capability. That's a separate, OS-level limit; it's not affected by the session name.
Cleanup for `cliDaemon` processes: stop your session's daemon withnpx @playwright/cli -s=$PW_SESSION close, or nuke all stale daemons (after killing all the Code OSS windows) withnpx @playwright/cli kill-all. Session daemons live under~/Library/Caches/ms-playwright/daemon/<hash>/.
Agents window selector differences
The Agents window does not use the regular workbench .interactive-input-editor wrapper. Selector checks that are scoped to that wrapper may return nothing even when the Agents chat input is focused.
// Regular-workbench-specific selector; do not assume this exists in Agents.
document.querySelectorAll('.interactive-input-editor .view-line')
// More useful checks in Agents.
document.querySelectorAll('.view-line')
document.activeElement?.className === 'native-edit-context'The Control+Meta+i / Control+Alt+i focus shortcut still works; only the DOM shape after focus differs.
Verifying and clearing chat text
For the regular workbench sidebar, this confirms that text landed in the Monaco input:
npx @playwright/cli -s=$PW_SESSION eval '
(() => {
const sidebar = document.querySelector(".part.auxiliarybar");
const viewLines = sidebar?.querySelectorAll(".interactive-input-editor .view-line") ?? [];
return Array.from(viewLines).map(viewLine => viewLine.textContent).join("|");
})()'For the Agents window, use a fresh snapshot plus the broader selector/focus checks above instead of assuming the regular sidebar wrapper is present.
To clear the focused Monaco input:
# macOS
npx @playwright/cli -s=$PW_SESSION press Meta+a
# Linux / Windows
npx @playwright/cli -s=$PW_SESSION press Control+a
npx @playwright/cli -s=$PW_SESSION press BackspaceIf the keyboard shortcut cannot focus chat because the surface is not available yet, take a snapshot and navigate the UI into a state where chat exists before retrying. Avoid treating completed CLI commands as proof that text was entered.
Screenshots (paper trail)
SHOTS="$PWD/screenshots/$(date +%Y-%m-%dT%H-%M-%S)"
mkdir -p "$SHOTS"
npx @playwright/cli -s=$PW_SESSION screenshot --filename="$SHOTS/after-launch.png"Keep screenshots inside the workspace, not /tmp, so they survive for review.For wide windows, --full-page can make layout easier to inspect, and element screenshots are useful when a snapshot gives a stable ref for the panel you care about:
npx @playwright/cli -s=$PW_SESSION screenshot --full-page --filename="$SHOTS/full-window.png"
npx @playwright/cli -s=$PW_SESSION screenshot e42 --filename="$SHOTS/panel.png"On macOS, a screenshot "Permission denied" failure usually means the terminal lacks Screen Recording permission. Use text/state verification while resolving that permission issue.
Debug with dap-cli
To set breakpoints in VS Code source while the window is running, attach dap-cli to one of the ports. If dap-cli would help but the corresponding skill is unavailable, prompt the user to install it from https://github.com/roblourens/dap-cli before continuing with debugger-specific steps.
Read the `dap-cli` skill for the full attach/breakpoint/inspect workflow when it is available - this skill only tells you which port to point it at:
- Extension host (most common - Copilot Chat extension, built-in extensions, your own extension under development) ->
extHostPort - Main process (Electron lifecycle, window/menu wiring, IPC) ->
mainPort - Local agent host (
src/vs/platform/agentHost/node/..., agent session lifecycle, AHP wiring, Claude/Copilot agent providers) ->agentHostPort - Renderer (the workbench itself,
src/vs/workbench/...) ->cdpPort
You can run @playwright/cli and dap-cli against the same window simultaneously - drive the UI with one terminal, hit a breakpoint and inspect state in another.
Multiple instances
Every launch picks fresh ports and a fresh temp runDir, so you can run as many concurrent Code OSS windows as your machine can handle. Each one's ports come back in its own JSON blob - keep them separate.
The launcher also passes --shared-data-dir=<runDir>/shared-data. This is required for multi-instance isolation: Code OSS keeps a fixed-path SQLite DB at ~/.<dataFolderName>-shared/sharedStorage/state.vscdb that is not covered by --user-data-dir. Without overriding it, two concurrent instances would fight over the same file and one would die with "shared background process terminated unexpectedly". Each launch gets its own shared-data dir.
Restart after source changes
Workbench code is loaded when the Code OSS window starts; source changes are not hot-reloaded into an already-running instance. After the build output is current, kill the launched process, launch again, and reattach to the new cdpPort from the new JSON blob.
kill "$PID" 2>/dev/null || true
INFO=$("$LAUNCH" | tail -n1)
CDP=$(jq -r .cdpPort <<<"$INFO")
PID=$(jq -r .pid <<<"$INFO")
npx @playwright/cli -s=$PW_SESSION attach --cdp=http://127.0.0.1:$CDP
npx @playwright/cli -s=$PW_SESSION tab-list
npx @playwright/cli -s=$PW_SESSION snapshotIf you are iterating frequently, keep the repo build/watch task running separately so relaunches pick up already-generated output.
Cleanup
The launcher writes everything under a temp runDir (printed in the JSON). When you're done:
# Disconnect this session's playwright daemon (leaves other sessions' daemons alone)
npx @playwright/cli -s=$PW_SESSION close
# Or nuke any stale daemons left behind by crashed callers across all sessions:
# npx @playwright/cli kill-all
# Kill the Code OSS instance
kill "$PID" 2>/dev/null || true
# Or by port if you've lost the pid:
pids=$(lsof -t -i :$CDP); [ -n "$pids" ] && kill $pids
# Remove the throwaway profile
rm -rf "$(dirname "$LOG")"Code OSS is a full Electron app and easily eats 1-4 GB. Always clean up.
Troubleshooting
- "Sent env to running instance. Terminating..." - The dynamic
--user-data-dirshould prevent this. If you see it, another Code OSS is using the same profile path; pass--source-user-data-dirto a different source or check that the temp copy actually happened (ls "$(jq -r .userDataDir <<<"$INFO")"). - Renderer ESM errors / `import { Menu } from 'electron'` -
ELECTRON_RUN_AS_NODEis set in your env. The launcher unsets it for the child, but if you spawncode.shyourself, do the same. - Built-in extension fails to load (`Cannot find module .../extensions/.../out/extension.js`) - extensions weren't compiled. Run
npm run compile(one-shot, also rebuilds all built-in extensions) ornpm run watch(incremental). A common cause: you rannpm run transpile-clientto satisfy unit tests, which populatedout/but notextensions/*/out/, so preLaunch's "isout/missing?" check skipped the compile. - `launch.sh` exits non-zero with a log tail - either pre-launch failed,
code.shdied before CDP came up, or CDP never opened within 90s. The tail printed to stderr is fromrunDir/code.log- read it to diagnose. - Snapshot shows the wrong page or no expected controls - use
tab-list, switch withtab-select <index>if needed, then re-snapshot before interacting. - CLI typing commands complete but the input stays empty - focus chat with the platform shortcut, use
pressor clipboard paste rather thanfill/type, then verify the input state before sending. - Auth missing in the launched window - confirm the source profile is actually authed (
ls "$SOURCE_UDD"should containUser/, andls "$SOURCE_UDD/User/globalStorage"should show persisted extension state). Some auth lives in the OS keychain - that's per-user, so it follows automatically as long as you're running as the same user.
#!/usr/bin/env bash
# Launch Code OSS (VS Code from sources) with:
# - a fresh, slimmed copy of the authenticated user-data-dir (so Copilot/GitHub auth works)
# - an isolated --shared-data-dir (otherwise two instances share ~/.vscode-oss-shared and crash each other)
# - unique debug ports for renderer (CDP), extension host, main process, and agent host
#
# Auth on macOS comes from the OS keychain (per-app, shared automatically) plus
# the encrypted blob in User/globalStorage/state.vscdb (per-UDD). The slim copy
# keeps the auth-relevant state and drops caches / workspaceStorage / logs.
#
# Prints a single JSON line to stdout with the chosen ports + paths so the
# caller can pick them up programmatically. Logs go to stderr.
#
# Usage:
# launch.sh [--agents] [--source-user-data-dir <path>] [--repo <vscode-repo-root>]
# [--clone-extensions] [--full] [-- <extra code.sh args>]
#
# Flags:
# --clone-extensions Copy the source extensions/ into the new profile (~10s).
# Default: start with an EMPTY extensions/ dir - fastest
# and conflict-free, but no third-party extensions.
# --full Copy the entire profile (incl. extensions). Use if the
# slim copy is missing something you need.
#
# Defaults:
# --source-user-data-dir $CODE_OSS_DEV_AUTHED_USER_DATA_DIR (else ~/.vscode-oss-dev)
# --repo $PWD if it looks like a vscode checkout; otherwise pass it explicitly
set -euo pipefail
umask 077
AGENTS=0
SOURCE_UDD="${CODE_OSS_DEV_AUTHED_USER_DATA_DIR:-$HOME/.vscode-oss-dev}"
REPO=""
EXTRA_ARGS=()
CLONE_EXTENSIONS=0
FULL=0
while [[ $# -gt 0 ]]; do
case "$1" in
--agents) AGENTS=1; shift ;;
--source-user-data-dir) SOURCE_UDD="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--clone-extensions|--copy-extensions) CLONE_EXTENSIONS=1; shift ;;
--full) FULL=1; shift ;;
--) shift; EXTRA_ARGS=("$@"); break ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
if [[ -z "$REPO" ]]; then
if [[ -x "$PWD/scripts/code.sh" ]]; then
REPO="$PWD"
else
echo "Could not find a vscode checkout in $PWD. Pass --repo <path>." >&2
exit 2
fi
fi
if [[ ! -d "$SOURCE_UDD" ]]; then
echo "Source user-data-dir does not exist: $SOURCE_UDD" >&2
echo "Pass --source-user-data-dir <path> or set CODE_OSS_DEV_AUTHED_USER_DATA_DIR." >&2
exit 2
fi
pick_port() {
node -e '
const net = require("net");
const s = net.createServer();
s.listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => console.log(p)); });
'
}
CDP_PORT=$(pick_port)
EXTHOST_PORT=$(pick_port)
MAIN_PORT=$(pick_port)
AGENTHOST_PORT=$(pick_port)
STAMP=$(date +%Y%m%d-%H%M%S)-$$
RUN_DIR="${TMPDIR:-/tmp}/code-oss-dev/$STAMP"
DEST_UDD="$RUN_DIR/user-data"
SHARED_DATA_DIR="$RUN_DIR/shared-data"
mkdir -p "$DEST_UDD" "$SHARED_DATA_DIR"
# Excludes (deny-list, so future VS Code additions copy through by default).
# Anchored excludes (starting with /) match only at the top level so we don't
# accidentally strip files inside subdirs that share a name.
EXCLUDES=(
'/extensions' # handled separately below
'/workspaceStorage' 'User/workspaceStorage' # per-workspace state, incl. chat sessions
'User/History' # local file edit history
'/CachedExtensionVSIXs' # backup VSIXs
'/logs'
'/Cache' '/Code Cache' '/CachedData' '/component_crx_cache'
'/GPUCache' '/ShaderCache' '/Dawn*Cache'
'/Backups' '/blob_storage' '/BrowserMetrics' '/Crashpad'
'/Session Storage'
'/Singleton*'
'*.lock' '*.sock'
)
if [[ "$FULL" == "1" ]]; then
echo "[launch.sh] full copy: $SOURCE_UDD -> $DEST_UDD" >&2
rsync -a "$SOURCE_UDD/" "$DEST_UDD/"
else
echo "[launch.sh] slim copy: $SOURCE_UDD -> $DEST_UDD" >&2
RSYNC_ARGS=(-a)
for e in "${EXCLUDES[@]}"; do RSYNC_ARGS+=(--exclude="$e"); done
rsync "${RSYNC_ARGS[@]}" "$SOURCE_UDD/" "$DEST_UDD/"
fi
# Extensions:
# --full -> already copied above
# --clone-extensions -> copy into the new profile (~10s)
# default -> fresh empty dir
EXT_DIR="$DEST_UDD/extensions"
mkdir -p "$EXT_DIR"
if [[ "$FULL" != "1" && "$CLONE_EXTENSIONS" == "1" ]]; then
echo "[launch.sh] copying extensions: $SOURCE_UDD/extensions -> $EXT_DIR" >&2
rsync -a "$SOURCE_UDD/extensions/" "$EXT_DIR/"
fi
# Force the simple (quick-input) file dialog so automation can drive
# "Open Folder" / workspace pickers. The native OS file dialog cannot be
# controlled by @playwright/cli over CDP (and is completely unreachable
# over SSH on headless macOS). The setting overlay is per-launch and
# always applied because every launched instance under this skill is
# a throwaway used for automation.
SETTINGS_FILE="$DEST_UDD/User/settings.json"
mkdir -p "$(dirname "$SETTINGS_FILE")"
# Data-preserving text-based merge: insert/update `files.simpleDialog.enable`
# without reparsing the whole file. Avoids dropping user comments and
# string values containing `//` (e.g. URLs). Fails loudly if the file
# exists but has no recognizable JSON object shape — never silently
# overwrites with `{}`.
if ! node - "$SETTINGS_FILE" <<'NODE'
const fs = require('fs');
const f = process.argv[2];
const KEY = 'files.simpleDialog.enable';
let text;
try { text = fs.readFileSync(f, 'utf8'); }
catch (e) {
if (e.code === 'ENOENT') text = '';
else { console.error('[launch.sh] cannot read ' + f + ': ' + e.message); process.exit(1); }
}
// Empty file → write a fresh object.
if (text.trim() === '') {
fs.writeFileSync(f, '{\n "' + KEY + '": true\n}\n');
process.exit(0);
}
// Key already present (with any value) → update its value to `true`
// via a targeted regex on the value slot only.
const keyValueRe = new RegExp('("' + KEY.replace(/\./g, '\\.') + '"\\s*:\\s*)(true|false|null|"[^"\\n]*"|-?\\d+(?:\\.\\d+)?)', 'g');
if (keyValueRe.test(text)) {
const updated = text.replace(keyValueRe, '$1true');
fs.writeFileSync(f, updated);
process.exit(0);
}
// Otherwise: find the LAST `}` and insert the new key before it.
// We deliberately don't parse JSONC — this preserves comments and
// any other content the source profile had.
const lastBrace = text.lastIndexOf('}');
if (lastBrace === -1) {
console.error('[launch.sh] settings.json has no closing brace — refusing to clobber it: ' + f);
process.exit(1);
}
// Decide whether to add a leading comma. If the only thing between the
// first `{` and the last `}` is whitespace and comments, the object is
// empty for our purposes and no comma is needed.
const firstBrace = text.indexOf('{');
if (firstBrace === -1 || firstBrace >= lastBrace) {
console.error('[launch.sh] settings.json has no opening brace — refusing to clobber it: ' + f);
process.exit(1);
}
const between = text.slice(firstBrace + 1, lastBrace)
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\n]*/g, '')
.trim();
const insertion = between.length === 0
? '\n "' + KEY + '": true\n'
: ',\n "' + KEY + '": true\n';
fs.writeFileSync(f, text.slice(0, lastBrace) + insertion + text.slice(lastBrace));
NODE
then
echo "[launch.sh] failed to ensure files.simpleDialog.enable=true in $SETTINGS_FILE — automation may need to fall back to per-key input" >&2
exit 1
fi
echo "[launch.sh] ensured files.simpleDialog.enable=true in $SETTINGS_FILE" >&2
# Strip ELECTRON_RUN_AS_NODE, commonly inherited from VS Code's integrated
# terminal / agent runtimes; it breaks ./scripts/code.sh.
unset ELECTRON_RUN_AS_NODE
CODE_SH="$REPO/scripts/code.sh"
if [[ ! -x "$CODE_SH" ]]; then
echo "Could not find an executable Code OSS launcher at $CODE_SH. Pass --repo <vscode-repo-root>." >&2
exit 2
fi
ARGS=(
"--user-data-dir=$DEST_UDD"
"--extensions-dir=$EXT_DIR"
"--shared-data-dir=$SHARED_DATA_DIR"
"--remote-debugging-port=$CDP_PORT"
"--inspect-extensions=$EXTHOST_PORT"
"--inspect=$MAIN_PORT"
"--inspect-agenthost=$AGENTHOST_PORT"
)
if [[ "$AGENTS" == "1" ]]; then
ARGS=("--agents" "${ARGS[@]}")
fi
if (( ${#EXTRA_ARGS[@]} )); then
ARGS+=("${EXTRA_ARGS[@]}")
fi
LOG_FILE="$RUN_DIR/code.log"
echo "[launch.sh] launching: $CODE_SH ${ARGS[*]}" >&2
echo "[launch.sh] logs: $LOG_FILE" >&2
# Run pre-launch (electron download, compile-if-missing, built-in extensions) in the
# foreground so any errors surface synchronously. Then skip code.sh's own pre-launch.
echo "[launch.sh] running pre-launch (ensures electron + compiled output + built-ins)..." >&2
if ! ( cd "$REPO" && node build/lib/preLaunch.ts ) >>"$LOG_FILE" 2>&1; then
echo "[launch.sh] pre-launch FAILED. Log tail:" >&2
tail -n 80 "$LOG_FILE" >&2
exit 1
fi
# Launch code.sh in the background. Detaching with `nohup ... & disown` is
# sufficient: by the time we return below, CDP is up and Electron is fully
# forked into its own process tree, so it's robust to its launching shell
# going away. (Earlier failures came from returning while Electron was still
# mid-bootstrap, not from process-group concerns.)
nohup env VSCODE_SKIP_PRELAUNCH=1 "$CODE_SH" "${ARGS[@]}" \
</dev/null >>"$LOG_FILE" 2>&1 &
PID=$!
disown $PID 2>/dev/null || true
# Block until the renderer's CDP endpoint is responding so the caller can attach
# immediately. If code.sh dies or we time out, dump the log so the failure is
# visible.
echo "[launch.sh] waiting for CDP on port $CDP_PORT (timeout 90s)..." >&2
READY=0
for i in $(seq 1 90); do
if ! kill -0 "$PID" 2>/dev/null; then
echo "[launch.sh] code.sh (PID $PID) exited before CDP came up. Log tail:" >&2
tail -n 80 "$LOG_FILE" >&2
exit 1
fi
if curl -sf -o /dev/null --max-time 1 "http://127.0.0.1:$CDP_PORT/json/version" 2>/dev/null; then
READY=1
echo "[launch.sh] CDP ready after ${i}s" >&2
break
fi
sleep 1
done
if [[ "$READY" != "1" ]]; then
echo "[launch.sh] timed out waiting for CDP on port $CDP_PORT. Log tail:" >&2
tail -n 80 "$LOG_FILE" >&2
exit 1
fi
node -e '
console.log(JSON.stringify({
pid: '"$PID"',
cdpPort: '"$CDP_PORT"',
extHostPort: '"$EXTHOST_PORT"',
mainPort: '"$MAIN_PORT"',
agentHostPort: '"$AGENTHOST_PORT"',
userDataDir: process.argv[1],
extensionsDir: process.argv[2],
sharedDataDir: process.argv[3],
runDir: process.argv[4],
logFile: process.argv[5],
repo: process.argv[6],
agents: '"$AGENTS"' === 1,
}));
' "$DEST_UDD" "$EXT_DIR" "$SHARED_DATA_DIR" "$RUN_DIR" "$LOG_FILE" "$REPO"#!/usr/bin/env bash
# monaco-paste.sh — insert text into the Code OSS chat input (Monaco) via
# the currently-attached @playwright/cli connection. Synthesizes a
# ClipboardEvent('paste') with a DataTransfer payload — no system
# clipboard involved, so safe to use against multiple parallel Code OSS
# instances (each subagent's @playwright/cli attaches to its own CDP).
#
# Why this exists: VS Code Monaco's `native-edit-context` element doesn't
# react to `@playwright/cli`'s `fill` or `type`. The pbcopy+press alternative
# works for one instance but `pbcopy` writes the system-wide NSPasteboard,
# so two parallel callers can stomp each other's clipboards.
#
# Usage:
# echo "the prompt text" | scripts/monaco-paste.sh
# scripts/monaco-paste.sh "the prompt text"
# scripts/monaco-paste.sh --append "additional text" # don't clear first
# scripts/monaco-paste.sh --no-verify "..." # skip read-back check
# scripts/monaco-paste.sh --session NAME "..." # use a named @playwright/cli session
# # (also honored via $PW_SESSION env var;
# # required for parallel multi-instance runs
# # — see SKILL.md "Typing into Monaco")
#
# Stdout: a single JSON line, e.g.
# {"ok":true,"actualLength":47,"expectedLength":47,"viewLineCount":1,"firstViewLine":"..."}
# Stderr: diagnostic noise from @playwright/cli (suppressed unless caller wants it).
# Exit code:
# 0 success
# 1 paste verify failed, eval failed, or the page had no native-edit-context
# 2 argument/usage error (empty input, missing tools)
#
# Required tools on PATH: npx (with @playwright/cli reachable), node, jq.
#
# Assumes:
# - You have already run `npx @playwright/cli [-s=NAME] attach --cdp=http://127.0.0.1:$CDP`
# in the same session this script reads (--session arg, $PW_SESSION env, or "default").
# - The Agents window is open and a new-chat / chat view with a Monaco
# editor is on screen. The script auto-focuses the first
# `.new-chat-input-area .native-edit-context`, falling back to any
# `.native-edit-context`.
set -u
umask 077
APPEND=0
VERIFY=1
TEXT_ARG=""
PW_SESSION_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--append) APPEND=1; shift ;;
--no-verify) VERIFY=0; shift ;;
--session) PW_SESSION_OVERRIDE="$2"; shift 2 ;;
--session=*) PW_SESSION_OVERRIDE="${1#--session=}"; shift ;;
-h|--help)
sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
--) shift; TEXT_ARG="${*-}"; break ;;
-*) echo "monaco-paste.sh: unknown flag $1" >&2; exit 2 ;;
*) TEXT_ARG="$1"; shift ;;
esac
done
# Resolve session: --session arg wins, then $PW_SESSION, then empty (cli default).
SESSION="${PW_SESSION_OVERRIDE:-${PW_SESSION:-}}"
PW_ARGS=()
[[ -n "$SESSION" ]] && PW_ARGS=("-s=$SESSION")
# Text: prefer the positional arg; otherwise read all of stdin.
# Stdin is preferred for arbitrary text because it avoids any shell
# quoting issues with backticks, $, ", newlines, etc.
if [[ -n "${TEXT_ARG:-}" ]]; then
TEXT="$TEXT_ARG"
else
TEXT=$(cat)
fi
if [[ -z "$TEXT" ]]; then
echo '{"ok":false,"error":"empty input"}' >&2
exit 2
fi
# Sanity: required tools on PATH.
for tool in npx node jq; do
if ! command -v "$tool" >/dev/null 2>&1; then
printf '{"ok":false,"error":"%s not on PATH"}\n' "$tool"
echo "monaco-paste.sh: required tool '$tool' not on PATH" >&2
exit 2
fi
done
# Pick the platform-appropriate "select all" modifier. macOS uses Cmd
# (Meta), everything else uses Ctrl. Done in the host shell so it
# applies to the `press` calls below — Monaco itself respects both
# bindings, but @playwright/cli only sends what we ask it to.
case "${OSTYPE:-$(uname -s)}" in
darwin*|Darwin*) SELECT_ALL_MOD="Meta" ;;
*) SELECT_ALL_MOD="Control" ;;
esac
# Step 1 (optional): clear the focused Monaco editor by select-all + delete.
# Done via the CLI's `press` so the keys flow through Monaco's real key
# handler. Stays inside the CDP connection — no system clipboard.
if [[ "$APPEND" != "1" ]]; then
npx @playwright/cli ${PW_ARGS[@]+"${PW_ARGS[@]}"} press "${SELECT_ALL_MOD}+a" >/dev/null 2>&1 || true
npx @playwright/cli ${PW_ARGS[@]+"${PW_ARGS[@]}"} press Backspace >/dev/null 2>&1 || true
fi
# Step 2: build the eval payload via node so JSON escaping is automatic.
# The async IIFE waits two requestAnimationFrames after dispatch — Monaco
# updates its view-line DOM asynchronously, so a same-tick read-back
# returns stale state. Two rAFs = full paint cycle.
JS=$(node -e '
const text = process.argv[1];
const verify = process.argv[2] === "1";
console.log(`(async () => {
const root = document.querySelector(".new-chat-input-area .native-edit-context")
|| document.querySelector(".sessions-chat-editor .native-edit-context")
|| document.querySelector(".native-edit-context");
if (!root) return JSON.stringify({ ok: false, error: "no native-edit-context found on page" });
root.focus();
const dt = new DataTransfer();
dt.setData("text/plain", ${JSON.stringify(text)});
root.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }));
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const editor = root.closest(".monaco-editor");
const viewLines = Array.from(editor.querySelectorAll(".view-line")).map(l => l.textContent);
// Monaco renders regular ASCII spaces as U+00A0 (NBSP) in view-lines for
// visual fidelity. Also, joining view-lines drops the logical newlines
// between them. Normalize both sides before comparing.
// (Note: \\u00A0 and \\r\\n are double-escaped because this string lives
// inside a node template literal that would otherwise resolve them.)
const norm = s => s.replace(/\\u00A0/g, " ").replace(/\\r?\\n/g, "");
const joined = norm(viewLines.join(""));
const actualLength = joined.length;
const expectedFull = norm(${JSON.stringify(text)});
const expectedPrefix = expectedFull.slice(0, Math.min(40, expectedFull.length));
const prefixMatched = joined.startsWith(expectedPrefix) || joined.includes(expectedPrefix.slice(0, 20));
const verifyEnabled = ${verify ? "true" : "false"};
return JSON.stringify({
ok: !verifyEnabled || prefixMatched,
actualLength,
expectedLength: ${JSON.stringify(text)}.length,
viewLineCount: viewLines.length,
firstViewLine: (viewLines[0] || "").slice(0, 80),
error: (!verifyEnabled || prefixMatched) ? undefined : "paste read-back did not match expected prefix"
});
})()`);
' "$TEXT" "$VERIFY")
# Step 3: run the eval. The CLI prints "### Result" then a JSON-encoded
# string on the next line, followed by "### Ran Playwright code" noise.
RAW=$(npx @playwright/cli ${PW_ARGS[@]+"${PW_ARGS[@]}"} eval "$JS" 2>&1) || {
echo "{\"ok\":false,\"error\":\"@playwright/cli eval failed\"}"
echo "$RAW" >&2
exit 1
}
RESULT_LINE=$(echo "$RAW" | grep -A 1 '### Result' | tail -n1)
if [[ -z "$RESULT_LINE" ]]; then
echo '{"ok":false,"error":"no ### Result section in eval output"}'
echo "$RAW" >&2
exit 1
fi
# RESULT_LINE is a JSON-encoded string containing our inner JSON.
# Unwrap once with jq.
CLEAN=$(echo "$RESULT_LINE" | jq -r 'fromjson' 2>/dev/null) || {
echo "{\"ok\":false,\"error\":\"failed to parse result line\",\"raw\":$(echo "$RESULT_LINE" | jq -Rs .)}"
exit 1
}
echo "$CLEAN"
OK=$(echo "$CLEAN" | jq -r '.ok')
[[ "$OK" == "true" ]]
Related skills
FAQ
What does launch do?
Launch Code OSS (VS Code from sources) into an isolated throwaway profile with unique debug ports so you can drive it with @playwright/cli AND attach a Node debugger via dap-cli in the sa...
When should I use launch?
Invoke when Launch Code OSS (VS Code from sources) into an isolated throwaway profile with unique debug ports so you can drive it with @playwright/cli A.
Is launch safe to install?
Review the Security Audits panel on this page before installing in production.