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
4 changes: 3 additions & 1 deletion contracts/runpane/contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -3298,6 +3298,7 @@
"rules": [
"Start with `runpane doctor --json` to understand wrapper, platform, daemon reachability, and the next safe commands before mutating Pane state.",
"Use `runpane agent-context --json` for the full agent-facing CLI context, or `runpane agent-context --command <command> --json` for one detailed command definition.",
"For `agent-context --command`, use canonical spaced names like `panes create`; copied forms like `panes.create` or `runpane panes create` are accepted too.",
"Use `runpane repos list --json` to find the saved repository when unsure after doctor shows the daemon is reachable.",
"If WSL cannot reach Pane or `runpane` resolves to a broken Windows shim, the user may be running Windows Pane; try `powershell.exe -NoProfile -Command 'Set-Location $env:TEMP; runpane doctor --json'`.",
"If the repository exists on disk but is not saved in Pane, use `runpane repos add --path <repo> --yes --json` before creating panes.",
Expand Down Expand Up @@ -3684,7 +3685,8 @@
"agentContextCommandResult"
],
"notes": [
"Default output is brief so AGENTS.md can point here without bloating context."
"Default output is brief so AGENTS.md can point here without bloating context.",
"`--command` accepts canonical spaced names and common copied forms, including `panes.create` and `runpane panes create`."
]
},
"repos list": {
Expand Down
9 changes: 8 additions & 1 deletion packages/runpane-py/src/runpane/agent_context.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import re
from typing import Any, Dict, List, Optional

from .generated_contract import RUNPANE_CONTRACT
Expand Down Expand Up @@ -41,13 +42,19 @@ def build_agent_context_result(command_name: Optional[str] = None) -> Dict[str,


def get_command_detail(command_name: str) -> Dict[str, Any]:
normalized = normalize_command_name(command_name)
for command in RUNPANE_CONTRACT["agentContext"]["commands"].values():
if command["name"] == command_name:
if normalize_command_name(command["name"]) == normalized:
return command

raise ValueError(f"Unknown runpane command: {command_name}. Expected one of: {', '.join(command_names())}")


def normalize_command_name(command_name: str) -> str:
without_binary = re.sub(r"^runpane\s+", "", command_name.strip(), flags=re.IGNORECASE)
return re.sub(r"[._\s-]+", "", without_binary.lower())


def render_brief(result: Dict[str, Any]) -> str:
lines = [
RUNPANE_CONTRACT["agentContext"]["brief"]["title"],
Expand Down
22 changes: 16 additions & 6 deletions packages/runpane-py/src/runpane/doctor.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, Optional

from .daemon_client import get_pane_daemon_endpoint, invoke_daemon, resolve_pane_directory
Expand All @@ -10,6 +11,7 @@
from .version import pane_version, wrapper_version

DOCTOR_DAEMON_TIMEOUT_MS = 5_000
DOCTOR_RELEASE_TIMEOUT_SECONDS = 5


def run_doctor(parsed, source: str = "pip") -> int:
Expand All @@ -27,13 +29,20 @@ def build_doctor_report(parsed, source: str) -> Dict[str, Any]:
pane_dir = resolve_pane_directory(parsed.pane_dir)
endpoint = get_pane_daemon_endpoint(pane_dir)
platform_result = collect_platform()
release = (
collect_release_check(parsed, source, platform_result["platform"])
if platform_result["ok"]
else {"ok": False, "error": platform_result["error"]}
)
with ThreadPoolExecutor(max_workers=2) as executor:
release_future = (
executor.submit(collect_release_check, parsed, source, platform_result["platform"])
if platform_result["ok"]
else None
)
daemon_future = executor.submit(collect_daemon_health, parsed.pane_dir, endpoint)
release = (
release_future.result()
if release_future
else {"ok": False, "error": platform_result["error"]}
)
daemon = daemon_future.result()
installed_pane = collect_installed_pane(parsed.pane_path)
daemon = collect_daemon_health(parsed.pane_dir, endpoint)

return {
"ok": bool(release["ok"] and daemon["reachable"]),
Expand Down Expand Up @@ -72,6 +81,7 @@ def collect_release_check(parsed, source: str, platform: PanePlatform) -> Dict[s
platform=platform,
format_name=parsed.format,
target="client",
fetch_timeout_seconds=DOCTOR_RELEASE_TIMEOUT_SECONDS,
)
return {
"ok": True,
Expand Down
2 changes: 1 addition & 1 deletion packages/runpane-py/src/runpane/generated_contract.py

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion packages/runpane-py/src/runpane/local_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ def build_panel_create_request(parsed: Any) -> Dict[str, Any]:

def build_pane_create_request(parsed: Any) -> Dict[str, Any]:
if parsed.from_json:
payload = json.loads(read_input_source(parsed.from_json))
payload = json.loads(strip_utf8_bom(read_input_source(parsed.from_json)))
if not isinstance(payload, dict):
raise ValueError("--from-json payload must be an object.")
if parsed.dry_run:
Expand Down Expand Up @@ -456,6 +456,10 @@ def read_input_source(source: str) -> str:
return handle.read()


def strip_utf8_bom(value: str) -> str:
return value.lstrip("\ufeff")


def print_json(value: Any) -> None:
print(json.dumps(value, indent=2))

Expand Down
7 changes: 4 additions & 3 deletions packages/runpane-py/src/runpane/releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ def resolve_release(
platform: PanePlatform,
format_name: str,
target: str,
fetch_timeout_seconds: float = 30,
) -> ResolvedRelease:
release = fetch_release(version)
release = fetch_release(version, timeout_seconds=fetch_timeout_seconds)
selected_format = default_format(platform, target) if format_name == "auto" else format_name
artifact = find_artifact(release, platform, selected_format)
preferred = build_preferred_download_url(channel, source, platform, selected_format, release)
Expand All @@ -48,13 +49,13 @@ def resolve_release(
)


def fetch_release(version: str) -> Dict[str, Any]:
def fetch_release(version: str, timeout_seconds: float = 30) -> Dict[str, Any]:
normalized = "latest" if version == "latest" else f"tags/{version if version.startswith('v') else 'v' + version}"
req = urllib.request.Request(
f"{GITHUB_API_BASE}/{normalized}",
headers={"Accept": "application/vnd.github+json", "User-Agent": "runpane-installer"},
)
with urllib.request.urlopen(req, timeout=30) as response:
with urllib.request.urlopen(req, timeout=timeout_seconds) as response:
release = json.loads(response.read().decode("utf-8"))

if release.get("draft") or release.get("prerelease"):
Expand Down
11 changes: 9 additions & 2 deletions packages/runpane-py/src/runpane/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from typing import Optional
from . import __version__

PANE_VERSION_TIMEOUT_SECONDS = 2


def wrapper_version() -> str:
try:
Expand All @@ -20,8 +22,13 @@ def print_version(pane_path: object = None) -> int:

def pane_version(executable_path: str) -> Optional[str]:
try:
result = subprocess.run([executable_path, "--version"], capture_output=True, text=True, timeout=10)
except OSError:
result = subprocess.run(
[executable_path, "--version"],
capture_output=True,
text=True,
timeout=PANE_VERSION_TIMEOUT_SECONDS,
)
except (OSError, subprocess.TimeoutExpired):
return None
output = (result.stdout + result.stderr).strip()
return output or None
11 changes: 10 additions & 1 deletion packages/runpane/src/agentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,24 @@ export function buildAgentContextResult(commandName?: string): AgentContextResul
}

function getCommandDetail(commandName: string): AgentContextCommand {
const normalized = normalizeCommandName(commandName);
const detail = Object.values(RUNPANE_CONTRACT.agentContext.commands)
.find((command) => command.name === commandName);
.find((command) => normalizeCommandName(command.name) === normalized);
if (detail) {
return detail;
}

throw new Error(`Unknown runpane command: ${commandName}. Expected one of: ${commandNames().join(', ')}`);
}

function normalizeCommandName(commandName: string): string {
return commandName
.trim()
.replace(/^runpane\s+/i, '')
.toLowerCase()
.replace(/[._\s-]+/g, '');
}

function renderBrief(result: AgentContextBriefResult): string {
const lines = [
RUNPANE_CONTRACT.agentContext.brief.title,
Expand Down
11 changes: 7 additions & 4 deletions packages/runpane/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { resolveRelease } from './releases';
import { getPaneVersion, getWrapperVersion } from './version';

const DOCTOR_DAEMON_TIMEOUT_MS = 5_000;
const DOCTOR_RELEASE_TIMEOUT_MS = 5_000;

interface DaemonDoctorResult {
ok: true;
Expand Down Expand Up @@ -96,11 +97,12 @@ async function buildDoctorReport(parsed: ParsedArgs, source: 'npm' | 'pip'): Pro
const paneDir = resolvePaneDirectory(parsed.paneDir);
const endpoint = getPaneDaemonEndpoint(paneDir);
const platform = collectPlatform();
const release = platform.ok
? await collectReleaseCheck(parsed, source, platform.platform)
: { ok: false, error: platform.error };
const releasePromise = platform.ok
? collectReleaseCheck(parsed, source, platform.platform)
: Promise.resolve({ ok: false, error: platform.error });
const installedPane = collectInstalledPane(parsed.panePath);
const daemon = await collectDaemonHealth(parsed.paneDir, endpoint);
const daemonPromise = collectDaemonHealth(parsed.paneDir, endpoint);
const [release, daemon] = await Promise.all([releasePromise, daemonPromise]);

return {
ok: release.ok && daemon.reachable,
Expand Down Expand Up @@ -144,6 +146,7 @@ async function collectReleaseCheck(
platform,
format: parsed.format,
target: 'client',
fetchTimeoutMs: DOCTOR_RELEASE_TIMEOUT_MS,
});
return {
ok: true,
Expand Down
4 changes: 3 additions & 1 deletion packages/runpane/src/generated/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3300,6 +3300,7 @@ export const RUNPANE_CONTRACT = {
"rules": [
"Start with `runpane doctor --json` to understand wrapper, platform, daemon reachability, and the next safe commands before mutating Pane state.",
"Use `runpane agent-context --json` for the full agent-facing CLI context, or `runpane agent-context --command <command> --json` for one detailed command definition.",
"For `agent-context --command`, use canonical spaced names like `panes create`; copied forms like `panes.create` or `runpane panes create` are accepted too.",
"Use `runpane repos list --json` to find the saved repository when unsure after doctor shows the daemon is reachable.",
"If WSL cannot reach Pane or `runpane` resolves to a broken Windows shim, the user may be running Windows Pane; try `powershell.exe -NoProfile -Command 'Set-Location $env:TEMP; runpane doctor --json'`.",
"If the repository exists on disk but is not saved in Pane, use `runpane repos add --path <repo> --yes --json` before creating panes.",
Expand Down Expand Up @@ -3686,7 +3687,8 @@ export const RUNPANE_CONTRACT = {
"agentContextCommandResult"
],
"notes": [
"Default output is brief so AGENTS.md can point here without bloating context."
"Default output is brief so AGENTS.md can point here without bloating context.",
"`--command` accepts canonical spaced names and common copied forms, including `panes.create` and `runpane panes create`."
]
},
"repos list": {
Expand Down
6 changes: 5 additions & 1 deletion packages/runpane/src/localControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ async function buildPanelCreateRequest(parsed: ParsedArgs): Promise<PanelCreateR

async function buildPaneCreateRequest(parsed: ParsedArgs): Promise<PaneCreateRequest> {
if (parsed.fromJson) {
const payload = JSON.parse(readInputSource(parsed.fromJson)) as unknown;
const payload = JSON.parse(stripUtf8Bom(readInputSource(parsed.fromJson))) as unknown;
const request = parsePaneCreateRequestPayload(payload);
if (parsed.dryRun) {
request.dryRun = true;
Expand Down Expand Up @@ -858,6 +858,10 @@ function readInputSource(source: string): string {
return fs.readFileSync(source, 'utf8');
}

function stripUtf8Bom(value: string): string {
return value.replace(/^\uFEFF+/, '');
}

function printJson(value: unknown): void {
console.log(JSON.stringify(value, null, 2));
}
Expand Down
48 changes: 33 additions & 15 deletions packages/runpane/src/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ export interface ResolveReleaseOptions {
platform: PanePlatform;
format: ArtifactFormat;
target: 'client' | 'daemon';
fetchTimeoutMs?: number;
}

export async function resolveRelease(options: ResolveReleaseOptions): Promise<ResolvedRelease> {
const release = await fetchRelease(options.version);
const release = await fetchRelease(options.version, options.fetchTimeoutMs);
const format = options.format === 'auto' ? defaultFormat(options.platform, options.target) : options.format;
const artifact = findArtifact(release, options.platform, format);
const preferredDownloadUrl = buildPreferredDownloadUrl(options, format, release);
Expand All @@ -55,24 +56,41 @@ export async function resolveRelease(options: ResolveReleaseOptions): Promise<Re
};
}

export async function fetchRelease(version: string): Promise<GitHubRelease> {
export async function fetchRelease(version: string, timeoutMs?: number): Promise<GitHubRelease> {
const normalized = version === 'latest' ? 'latest' : version.startsWith('v') ? `tags/${version}` : `tags/v${version}`;
const response = await fetch(`${GITHUB_API_BASE}/${normalized}`, {
headers: {
accept: 'application/vnd.github+json',
'user-agent': 'runpane-installer'
const controller = timeoutMs ? new AbortController() : undefined;
const timeout = controller
? setTimeout(() => controller.abort(), timeoutMs)
: undefined;

try {
const response = await fetch(`${GITHUB_API_BASE}/${normalized}`, {
headers: {
accept: 'application/vnd.github+json',
'user-agent': 'runpane-installer'
},
signal: controller?.signal
});

if (!response.ok) {
throw new Error(`Failed to fetch Pane release ${version}: ${response.status} ${response.statusText}`);
}
});

if (!response.ok) {
throw new Error(`Failed to fetch Pane release ${version}: ${response.status} ${response.statusText}`);
}

const release = await response.json() as GitHubRelease;
if (release.draft || release.prerelease) {
throw new Error(`Release ${release.tag_name} is not a stable public release.`);
const release = await response.json() as GitHubRelease;
if (release.draft || release.prerelease) {
throw new Error(`Release ${release.tag_name} is not a stable public release.`);
}
return release;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Timed out fetching Pane release ${version} after ${timeoutMs}ms.`);
}
throw error;
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
return release;
}

export function findArtifact(
Expand Down
9 changes: 8 additions & 1 deletion packages/runpane/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import childProcess from 'child_process';
import fs from 'fs';
import path from 'path';

const PANE_VERSION_TIMEOUT_MS = 2_000;

export function getWrapperVersion(): string {
const packagePath = path.resolve(__dirname, '..', 'package.json');
try {
Expand All @@ -22,8 +24,13 @@ export function getPaneVersion(executablePath: string): string | undefined {
try {
const result = childProcess.spawnSync(executablePath, ['--version'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
stdio: ['ignore', 'pipe', 'pipe'],
timeout: PANE_VERSION_TIMEOUT_MS,
windowsHide: true
});
if (result.error) {
return undefined;
}
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
return output || undefined;
} catch {
Expand Down
Loading
Loading