Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 9 additions & 14 deletions .agents/skills/launch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ The clone is **slim**: workspace storage, browser caches, file history, cached V

## Prerequisites

- macOS or Linux. The launcher is a bash script and depends on `rsync`, `lsof`, `jq`, `nohup`, and Node on `PATH`.
- A VS Code checkout with sources built. Run `npm run watch` in another terminal (or rely on `./scripts/code.sh` to compile the first time).
- macOS or Linux. The launcher is a bash script and depends on `rsync`, `curl`, `nohup`, and Node on `PATH`. The example caller snippets below also use `jq` (parse the JSON output) and `lsof` (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 sources built. Run `npm run compile` once (one-shot) or `npm run watch` for incremental rebuilds. Both build the full client **and** the Copilot extension. The launcher also runs `node build/lib/preLaunch.ts` before starting Code OSS, which auto-runs `npm run compile` if `out/` is missing and downloads Electron + built-in extensions.
- An **authenticated** Code OSS profile to seed from. By default the launcher uses `~/.vscode-oss-dev`, which is the user-data-dir the repo's `launch.json` configs 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/Code` install).
- `@playwright/cli` available (it's a devDependency in the vscode repo - `npm install` then use `npx @playwright/cli`).
- For debugger work: `dap-cli` on `PATH`. If debugger support would be useful but the `dap-cli` skill is not present, prompt the user to install it from https://github.com/roblourens/dap-cli.
Expand Down Expand Up @@ -61,13 +61,13 @@ Excluded (transient, regenerable, or known-not-needed):

> If the launched window says "language model unavailable" or otherwise looks unauthed, ask the user to sign in.

The script prints one JSON line on stdout (logs go to stderr):
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.

```json
{"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`:
Capture it with `jq` — no retry loop needed, CDP is already up when the JSON is printed:

```bash
INFO=$("$LAUNCH" | tail -n1)
Expand All @@ -93,11 +93,8 @@ PID=$(jq -r .pid <<<"$INFO")
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.

```bash
# Wait for Code OSS to start, retry until attached
for i in 1 2 3 4 5; do
npx @playwright/cli attach --cdp=http://127.0.0.1:$CDP 2>/dev/null && break || sleep 3
done

# launch.sh blocks until CDP is ready, so a single attach is enough.
npx @playwright/cli attach --cdp=http://127.0.0.1:$CDP
npx @playwright/cli tab-list
npx @playwright/cli snapshot
```
Expand Down Expand Up @@ -234,9 +231,7 @@ kill "$PID" 2>/dev/null || true
INFO=$("$LAUNCH" | tail -n1)
CDP=$(jq -r .cdpPort <<<"$INFO")
PID=$(jq -r .pid <<<"$INFO")
for i in 1 2 3 4 5; do
npx @playwright/cli attach --cdp=http://127.0.0.1:$CDP 2>/dev/null && break || sleep 3
done
npx @playwright/cli attach --cdp=http://127.0.0.1:$CDP
npx @playwright/cli tab-list
npx @playwright/cli snapshot
```
Expand Down Expand Up @@ -266,8 +261,8 @@ Code OSS is a full Electron app and easily eats 1-4 GB. Always clean up.

- **"Sent env to running instance. Terminating..."** - The dynamic `--user-data-dir` should prevent this. If you see it, another Code OSS is using the same profile path; pass `--source-user-data-dir` to 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_NODE` is set in your env. The launcher unsets it for the child, but if you spawn `code.sh` yourself, do the same.
- **Built-in extension fails to load (`Cannot find module .../extensions/.../out/extension.js`)** - extensions weren't compiled. Run `npm run watch-extensions` (or `npm run compile-extensions`).
- **CDP connect refused** - give it a few seconds; the launcher returns before the renderer is ready. Use the retry loop above.
- **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 the Copilot extension) or `npm run watch` (incremental).
- **`launch.sh` exits non-zero with a log tail** - either pre-launch failed, `code.sh` died before CDP came up, or CDP never opened within 90s. The tail printed to stderr is from `runDir/code.log` - read it to diagnose.
- **Snapshot shows the wrong page or no expected controls** - use `tab-list`, switch with `tab-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 `press` or clipboard paste rather than `fill` / `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 contain `User/`, and `ls "$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.
42 changes: 41 additions & 1 deletion .agents/skills/launch/scripts/launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,48 @@ LOG_FILE="$RUN_DIR/code.log"
echo "[launch.sh] launching: $CODE_SH ${ARGS[*]}" >&2
echo "[launch.sh] logs: $LOG_FILE" >&2

nohup "$CODE_SH" "${ARGS[@]}" >"$LOG_FILE" 2>&1 &
# 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({
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"check-cyclic-dependencies": "node build/lib/checkCyclicDependencies.ts out",
"preinstall": "node build/npm/preinstall.ts",
"postinstall": "node build/npm/postinstall.ts",
"compile": "npm run gulp compile",
"compile": "npm-run-all2 -lp compile-client compile-copilot",
"compile-client": "npm run gulp compile",
"compile-copilot": "npm --prefix extensions/copilot run compile",
"compile-check-ts-native": "tsgo --project ./src/tsconfig.json --noEmit --skipLibCheck",
"watch": "npm-run-all2 -lp watch-client-transpile watch-client watch-extensions watch-copilot",
"watchd": "deemon npm run watch",
Expand Down
1 change: 1 addition & 0 deletions src/vs/platform/agentHost/common/sshConfigParsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function parseSSHGOutput(stdout: string): ISSHResolvedConfig {
user: map.get('user') || undefined,
port: parseInt(map.get('port') ?? '22', 10),
identityFile: identityFiles,
identityAgent: map.get('identityagent') || undefined,
forwardAgent: map.get('forwardagent') === 'yes',
};
}
3 changes: 3 additions & 0 deletions src/vs/platform/agentHost/common/sshRemoteAgentHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface ISSHAgentHostConfig {
readonly authMethod: SSHAuthMethod;
/** Path to the private key file (when {@link authMethod} is KeyFile). */
readonly privateKeyPath?: string;
/** Raw IdentityAgent value from resolved SSH config; may be a socket path, `none`, `SSH_AUTH_SOCK`, or an environment reference. */
readonly identityAgent?: string;
/** Password string (when {@link authMethod} is Password). */
readonly password?: string;
/** Display name for this connection. */
Expand Down Expand Up @@ -155,6 +157,7 @@ export interface ISSHResolvedConfig {
readonly user: string | undefined;
readonly port: number;
readonly identityFile: string[];
readonly identityAgent: string | undefined;
readonly forwardAgent: boolean;
}

Expand Down
76 changes: 71 additions & 5 deletions src/vs/platform/agentHost/node/agentHostGitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { generateUuid } from '../../../base/common/uuid.js';
import { INativeEnvironmentService } from '../../environment/common/environment.js';
import { IFileService } from '../../files/common/files.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { ILogService } from '../../log/common/log.js';
import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js';
import { buildGitBlobUri } from './gitDiffContent.js';

Expand Down Expand Up @@ -179,6 +180,7 @@ export class AgentHostGitService implements IAgentHostGitService {
constructor(
@IFileService private readonly _fileService: IFileService,
@INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
@ILogService private readonly _logService: ILogService,
) { }

async isInsideWorkTree(workingDirectory: URI): Promise<boolean> {
Expand Down Expand Up @@ -250,15 +252,15 @@ export class AgentHostGitService implements IAgentHostGitService {
// tracking from the start point (e.g. when starting from
// 'origin/main', without --no-track git would set the new branch's
// upstream to origin/main, which would mis-attribute pushes/pulls).
await this._runGit(repositoryRoot, ['worktree', 'add', '--no-track', '-b', branchName, worktree.fsPath, resolvedStartPoint], { timeout: 30_000, throwOnError: true });
await this._runGit(repositoryRoot, ['worktree', 'add', '--no-track', '-b', branchName, worktree.fsPath, resolvedStartPoint], { timeout: 60_000, throwOnError: true });
}

async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise<void> {
await this._runGit(repositoryRoot, ['worktree', 'add', worktree.fsPath, branchName], { timeout: 30_000, throwOnError: true });
await this._runGit(repositoryRoot, ['worktree', 'add', worktree.fsPath, branchName], { timeout: 60_000, throwOnError: true });
}

async removeWorktree(repositoryRoot: URI, worktree: URI): Promise<void> {
await this._runGit(repositoryRoot, ['worktree', 'remove', '--force', worktree.fsPath], { timeout: 30_000, throwOnError: true });
await this._runGit(repositoryRoot, ['worktree', 'remove', '--force', worktree.fsPath], { timeout: 60_000, throwOnError: true });
}

async branchExists(repositoryRoot: URI, branchName: string): Promise<boolean> {
Expand Down Expand Up @@ -542,24 +544,88 @@ export class AgentHostGitService implements IAgentHostGitService {
private _runGit(workingDirectory: URI, args: readonly string[], options?: { readonly timeout?: number; readonly throwOnError?: boolean; readonly env?: Record<string, string>; readonly maxBuffer?: number }): Promise<string | undefined> {
return new Promise((resolve, reject) => {
const env = options?.env ? { ...process.env, ...options.env } : undefined;
const timeoutMs = options?.timeout ?? 5000;
// Use our own timer rather than execFile's `timeout` option so
// we can definitively flag the timeout case in the error
// message — execFile only surfaces signal/killed, which can
// also mean the process was killed for other reasons.
let didTimeOut = false;
// Default maxBuffer is 32MB — Node's default is ~1MB, which is
// easy to exceed for diff output in large repos. Exceeding it
// causes execFile to error and we'd silently drop the diff.
cp.execFile('git', [...args], { cwd: workingDirectory.fsPath, timeout: options?.timeout ?? 5000, env, maxBuffer: options?.maxBuffer ?? 32 * 1024 * 1024 }, (error, stdout, stderr) => {
const child = cp.execFile('git', [...args], { cwd: workingDirectory.fsPath, env, maxBuffer: options?.maxBuffer ?? 32 * 1024 * 1024 }, (error, stdout, stderr) => {
if (error) {
// stderr is summarized in the thrown error message to keep
// it readable; log the full unmodified output here so the
// raw progress/diagnostic text is still available.
if (stderr) {
this._logService.warn(`[agentHostGitService] git ${args.join(' ')} failed; full stderr:\n${stderr}`);
}
if (options?.throwOnError) {
reject(new Error(stderr || error.message));
reject(new Error(formatGitError(args, timeoutMs, didTimeOut, error, stderr), { cause: error }));
return;
}
resolve(undefined);
return;
}
resolve(stdout);
});
const timer = setTimeout(() => {
didTimeOut = true;
child.kill();
}, timeoutMs);
child.on('exit', () => clearTimeout(timer));
});
}
}

/**
* Builds a diagnostic error message for a failed `git` invocation that
* preserves the reason (timeout / signal / exit code) instead of just
* surfacing whatever happened to be on stderr. When `git` is killed by
* the timeout, stderr often contains only progress output (e.g.
* `Updating files: 0% (149/14834)`), so without the timeout indicator
* the bubbled-up error is misleading.
*
* Exported for tests.
*/
export function formatGitError(args: readonly string[], timeoutMs: number, didTimeOut: boolean, error: cp.ExecFileException, stderr: string): string {
const subcommand = args[0] ?? '(unknown)';
let reason: string;
if (didTimeOut) {
reason = `git ${subcommand} timed out after ${timeoutMs}ms`;
} else if (error.killed && error.signal) {
reason = `git ${subcommand} killed by ${error.signal}`;
} else if (typeof error.code === 'number') {
reason = `git ${subcommand} exited with code ${error.code}`;
} else {
reason = error.message;
}
const detail = summarizeStderrForError(stderr);
return detail ? `${reason}: ${detail}` : reason;
}

/**
* Squashes multi-line / carriage-return-heavy stderr (e.g. git progress
* meters that emit `Updating files: 0% (149/14834)\r...` repeatedly)
* into a single short line suitable for a one-liner error message.
* Keeps the most recent non-empty line and caps total length.
*
* Exported for tests.
*/
export function summarizeStderrForError(stderr: string): string {
if (!stderr) {
return '';
}
const lines = stderr.split(/[\r\n]+/g).map(line => line.trim()).filter(line => line.length > 0);
if (lines.length === 0) {
return '';
}
const last = lines[lines.length - 1];
const MAX = 200;
return last.length > MAX ? `${last.slice(0, MAX - 1)}…` : last;
}

/**
* The well-known SHA-1 of git's empty tree, used as a fallback when a
* repository has no commits (no `HEAD` to read into the temp index).
Expand Down
29 changes: 28 additions & 1 deletion src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import type { PermissionRequest } from '@github/copilot-sdk';
import { hasKey } from '../../../../base/common/types.js';
import { URI } from '../../../../base/common/uri.js';
import { appendEscapedMarkdownInlineCode, escapeMarkdownLinkLabel } from '../../../../base/common/htmlContent.js';
import { appendEscapedMarkdownInlineCode, escapeMarkdownLinkLabel, MarkdownString } from '../../../../base/common/htmlContent.js';
import { hash } from '../../../../base/common/hash.js';
import { localize } from '../../../../nls.js';
import type { IAgentToolPendingConfirmationSignal } from '../../common/agentService.js';
Expand Down Expand Up @@ -187,6 +187,11 @@ interface ICopilotSqlToolArgs {
query?: string;
}

/** Parameters for the `web_fetch` tool. */
interface ICopilotWebFetchToolArgs {
url: string;
}

/**
* Parameters for the `apply_patch` / `git_apply_patch` tools. The patch text
* itself lives in `input` using the V4A diff format (file headers like
Expand Down Expand Up @@ -403,6 +408,10 @@ function formatPathAsMarkdownLink(path: string): string {
return `[${basename(uri)}](${uri})`;
}

function formatUrlAsMarkdownLink(url: string): string {
return new MarkdownString().appendLink(url, truncate(url, 80)).value;
}

/**
* Wraps a localized message containing a markdown file link into a
* `StringOrMarkdown` object so the renderer treats it as markdown.
Expand Down Expand Up @@ -562,6 +571,13 @@ export function getInvocationMessage(toolName: string, displayName: string, para
const args = parameters as ICopilotSqlToolArgs | undefined;
return args?.description || localize('toolInvoke.sql', "Executing SQL query");
}
case CopilotToolName.WebFetch: {
const args = parameters as ICopilotWebFetchToolArgs | undefined;
if (args?.url) {
return md(localize('toolInvoke.webFetch', "Fetching {0}", formatUrlAsMarkdownLink(args.url)));
}
return localize('toolInvoke.webFetchGeneric', "Fetching URL");
}
case CopilotToolName.ExitPlanMode:
return localize('toolInvoke.exitPlanMode', "Presenting plan");
default:
Expand Down Expand Up @@ -665,6 +681,13 @@ export function getPastTenseMessage(toolName: string, displayName: string, param
const args = parameters as ICopilotSqlToolArgs | undefined;
return args?.description || localize('toolComplete.sql', "Executed SQL query");
}
case CopilotToolName.WebFetch: {
const args = parameters as ICopilotWebFetchToolArgs | undefined;
if (args?.url) {
return md(localize('toolComplete.webFetch', "Fetched {0}", formatUrlAsMarkdownLink(args.url)));
}
return localize('toolComplete.webFetchGeneric', "Fetched URL");
}
case CopilotToolName.ExitPlanMode:
return localize('toolComplete.exitPlanMode', "Exited plan mode");
default:
Expand Down Expand Up @@ -786,6 +809,10 @@ export function getToolInputString(toolName: string, parameters: Record<string,
const args = parameters as ICopilotRgToolArgs | undefined;
return args?.pattern ?? rawArguments;
}
case CopilotToolName.WebFetch: {
const args = parameters as ICopilotWebFetchToolArgs | undefined;
return args?.url ?? rawArguments;
}
default:
// For other tools, show the formatted JSON arguments
if (parameters) {
Expand Down
Loading
Loading