diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..ee49fa2d --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "veadk-python", + "interface": { + "displayName": "VeADK Python" + }, + "plugins": [ + { + "name": "agentkit-studio", + "source": { + "source": "local", + "path": "./plugins/agentkit-studio" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/frontend/README.md b/frontend/README.md index 42749fe9..1b0ae483 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -52,6 +52,11 @@ server that `veadk frontend` launches — no separate backend. standard Codex App Server threads. Leaving the conversation only disconnects it, so the Agent remains available until the user deletes it. OpenClaw and Hermes expose their main interface and Terminal through Studio. +- **Codex conversation handoff** creates a temporary cloud Sandbox, restores + the current Git worktree, injects only completed user-visible user and + assistant messages into the cloud Thread, then starts one new turn with + `继续` or the user's explicit cloud task. System/developer prompts, reasoning, + tool logs, local runtime databases, and SSH keys are never transferred. - **System information**: open a full page from the account menu to inspect the Studio version, configured Sandbox Tool IDs (with snapshot Tools badged), and available Identity user pools. Resource identifiers remain read-only and diff --git a/frontend/skills/agentkit-codex-handoff/SKILL.md b/frontend/skills/agentkit-codex-handoff/SKILL.md new file mode 100644 index 00000000..5604f1cc --- /dev/null +++ b/frontend/skills/agentkit-codex-handoff/SKILL.md @@ -0,0 +1,101 @@ +--- +name: agentkit-codex-handoff +description: Continue the current local Codex conversation and coding task in an AgentKit Studio cloud Codex Sandbox using a Studio URL and short one-time pairing code. Use when the user asks to hand off, migrate, or continue a local repository in Studio, especially when a prompt contains a Studio address and pairing code. Export the complete user-visible user and assistant message history from every prior turn, including interrupted turns and progress commentary, create a temporary Studio Sandbox, restore tracked and non-ignored working-tree files plus Git metadata, inject that visible history into the cloud Thread, transfer GitHub CLI authentication separately when needed, and send one final continuation message; never copy system or developer prompts, reasoning, tool logs, SQLite state, global configuration, local skills, or SSH private keys. +--- + +# Continue a Task in Studio + +Transfer the project plus the current task's visible conversation, not the local Codex runtime. Create one temporary Studio Sandbox with the supplied pairing code, restore the project, inject the complete visible history from every prior turn into its fresh cloud Thread, then send exactly one new user message so the cloud Session continues working. + +Accept a terse handoff prompt containing only the intent, Studio URL, and pairing code. When invoked immediately after Plugin installation, stay in the current Codex task, load this Skill explicitly if automatic discovery has not refreshed, and continue without asking the user to repeat the repository or task context. + +## Boundaries + +Include only: + +- Git tracked files and non-ignored untracked files, including working-tree edits and deletions +- repository-level `AGENTS.md` files selected by the same Git rules +- Git branch, HEAD commit, sanitized remote URL, and status metadata +- a generated `HANDOFF.md`; append the generated section when the project already has one, and keep the generated metadata out of Git commits +- every user and assistant message visible in the active Codex task before the current handoff turn, including progress commentary and interrupted turns +- local PNG, JPEG, GIF, or WebP images attached to those prior user messages + +Never include: + +- Codex system or developer prompts, reasoning summaries, tool calls or outputs, rollout JSONL, or SQLite state +- `$CODEX_HOME`, local or global Skills, plugin caches, or global Codex settings +- SSH private keys, non-GitHub credentials, or global Git configuration +- ignored files such as `.env` unless they are deliberately tracked + +Do not place conversation history in the repository, `HANDOFF.md`, retained project bundle, logs, or command output. Send it only through the authenticated one-time continuation request. Do not claim that the remote environment is an exact copy of local Codex; it is a visible-history and project handoff into a separately configured Studio runtime. + +The restore helper hides a newly generated `HANDOFF.md` through the repository-local Git exclude file. When the repository already tracks `HANDOFF.md`, it applies `skip-worktree` in the temporary cloud checkout so the appended transfer metadata is not staged by broad `git add` commands. Never commit generated transfer metadata. If the cloud task deliberately needs to edit a tracked `HANDOFF.md`, first remove the temporary flag with `git update-index --no-skip-worktree HANDOFF.md`, then delete the generated section before committing. + +## Workflow + +1. Resolve the repository from the user's `repo` value or the current working directory. Confirm it with `pwd`. +2. Resolve `studio_url` and `pairing_code` from the user's prompt. Treat the pairing code as a secret: do not print it, write it to a file, or place it in the bundle. +3. Create a concise Chinese task description for the cloud Agent name. Infer it yourself from the active task; do not ask the user to name it. Use at most 12 Unicode characters, omit punctuation and generic prefixes such as `Codex`, and describe the work rather than the repository. Examples: `完善端云接力`, `修复登录超时`, `优化订单检索`. +4. Export the active task's complete visible conversation with the Codex app task tools: + - Call `list_threads` and select the most recently updated active Codex task whose working directory matches the repository and whose title or summary matches the current objective. + - Identify the current handoff turn as the turn containing the pairing-code request being executed. Exclude that entire turn only; do not exclude any older turn based on `completed`, `interrupted`, failed, or other status. + - Call `read_thread` with `includeOutputs: false`, `maxOutputCharsPerItem: 20000`, and `turnLimit: 10`. The response is `newest_first`: save its `page.nextCursor`, call `read_thread` again with that cursor whenever `page.hasMore` is true, and continue until `page.hasMore` is false or 100 eligible visible messages have been collected. Never stop after the first page merely because it contains a completed turn. + - Reassemble pages and turns oldest-to-newest. Within each eligible turn, preserve item order and keep every `userMessage` and every `agentMessage` exactly once. Keep all user-visible assistant phases, including progress commentary before a final answer; do not merge, summarize, deduplicate, or replace them with the final answer. + - Exclude system/developer instructions, reasoning, tool calls and outputs, approvals, environment context, and other item types. Filtering is based on item type, never on turn status or `agentMessage.phase`. + - A delegated user message can contain an XML-looking `` wrapper. When the structured text part contains `codexDelegation.input`, use that value as the user-visible text and discard the wrapper. Never upload the wrapper or its `sourceThreadId`. + - Preserve user images. For `localImage`, record its absolute `path`. Also recognize Markdown images whose target is an absolute local path, such as `![截图](/tmp/example.png)`, and record the path plus alt text. The bundled script removes those local Markdown links from the visible text, validates ordinary non-symlink image files, and converts the bytes in memory for the one-time request. Do not copy images into the repository, `HANDOFF.md`, bundle, logs, or command output. + - Remove injected `...` blocks. When a user message contains `## My request:`, keep the text after that marker. + - Preserve oldest-to-newest order and exact message boundaries. Write a temporary mode-`0600` JSON file outside the repository using schema version 2, without printing its contents: + + ```json + {"schemaVersion":2,"messages":[{"role":"user","content":"...","images":[{"path":"/absolute/local/image.png","alt":"截图"}]},{"role":"assistant","content":"..."}]} + ``` + + - Omit `images` when a message has none. The script accepts schema version 1 for text-only compatibility, but new exports must use version 2. Before uploading, compare the JSON message count with the number of eligible `userMessage` and `agentMessage` items collected from all pages; stop on any mismatch. Stop instead of starting an empty cloud Thread when no visible messages can be exported. +5. Choose the final cloud user message. Use the user's explicit cloud instruction when one accompanies the handoff request; otherwise use exactly `继续`. Do not prepend project paths, migration explanations, or `HANDOFF.md` instructions to this visible message. +6. Create a temporary Markdown handoff outside the repository. Summarize only the current user objective, decisions, completed work, remaining work, validation results, and real blockers. Do not include system or developer prompts, conversation transcripts, credentials, or unrelated context. +7. For a GitHub remote, verify `gh auth token --hostname github.com` succeeds. GitHub authentication is transferred separately from the project bundle, installed with mode `0600`, and configured for HTTPS pushes. Never print the token. Convert an SSH-form GitHub remote to HTTPS instead of copying SSH keys. +8. Locate this Skill's `scripts/upload_current_dir.sh` and run a preview: + + ```bash + scripts/upload_current_dir.sh \ + --repo "$PWD" \ + --studio-url "$studio_url" \ + --agent-name "$agent_name" \ + --handoff "$handoff_file" \ + --history "$history_file" \ + --continue-message "$continue_message" \ + --dry-run + ``` + +9. Review the preview's file count, size, visible conversation message count, GitHub authentication status, and sensitive-file warnings. + - If high-risk filenames or possible secret assignments are present, stop and ask whether to exclude them locally or upload them with `--allow-sensitive`. + - If no sensitive-file warnings are present and the current user request explicitly asks to upload, that request is sufficient confirmation. +10. Run the live handoff without echoing the pairing code, conversation contents, or GitHub token: + + ```bash + AGENTKIT_STUDIO_PAIRING_CODE="$pairing_code" \ + scripts/upload_current_dir.sh \ + --repo "$PWD" \ + --studio-url "$studio_url" \ + --agent-name "$agent_name" \ + --handoff "$handoff_file" \ + --history "$history_file" \ + --continue-message "$continue_message" \ + --yes + ``` + + Keep the command attached until it returns. Relay each `[handoff] progress:` + line as concise progress to the user instead of waiting silently. The command + returns after Studio confirms that the cloud Codex completed the continuation and generated a visible reply; + the cloud task then keeps running independently. + +11. Delete the temporary history and handoff files after the command returns. Confirm that the script created a temporary Studio Sandbox, restored the project, kept generated handoff metadata out of Git status, injected the visible conversation history and images, and sent the final continuation message. Report the Sandbox display name, session ID, remote project directory, restored file count, imported message count, imported image count, Git status, GitHub authentication result, and continuation status. Do not claim success when the continuation stream reports an error or closes before completion. + +## Script options + +`--agent-name` is required for a live handoff and controls only the cloud Agent name. `--history` is required and accepts only the versioned visible-message JSON described above. Images are limited to 10 supported files, 4 MiB per image, and 8 MiB total. `--continue-message` defaults to `继续`. `--project-name` controls the bundle and remote directory and defaults to the repository basename. Use `--remote-home` only when Studio supports a different home, `--handoff` to append the task summary, and `--output` to retain a local copy of the generated project bundle. The script always creates a temporary Studio Sandbox. Use `--no-github-credentials` only when the user explicitly opts out; otherwise a GitHub remote requires working local `gh` authentication. + +The live command requires `--yes`. Add `--allow-sensitive` only after explicit approval for filenames or possible secret assignments identified by the preview. Visible history travels in the one-time Studio request and is injected with `thread/inject_items` before the final `turn/start`; old messages must never be replayed as turns. GitHub credentials travel in a separate ephemeral payload, never in the retained `--output` bundle, and the remote staging payload is deleted after installation. On failure, report the safe error and whether Studio already created a Session; never expose the pairing code, conversation contents, token, or returned private Sandbox endpoint. + +Large project bundles are uploaded in bounded parts, reassembled in order, and verified by SHA-256 before restore. If upload or restore fails after Session creation, the script reports the failed stage to Studio and a retry with the same pairing code resumes the existing Session instead of creating another one. Reuse the original pairing code for that retry while it remains valid. diff --git a/frontend/skills/agentkit-codex-handoff/agents/openai.yaml b/frontend/skills/agentkit-codex-handoff/agents/openai.yaml new file mode 100644 index 00000000..3dea110d --- /dev/null +++ b/frontend/skills/agentkit-codex-handoff/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Continue Task in Studio" + short_description: "Continue a local Codex conversation in Studio" + default_prompt: "Use $agentkit-codex-handoff to continue this conversation, project, and task in Studio." diff --git a/frontend/skills/agentkit-codex-handoff/scripts/upload_current_dir.sh b/frontend/skills/agentkit-codex-handoff/scripts/upload_current_dir.sh new file mode 100755 index 00000000..e91cb42c --- /dev/null +++ b/frontend/skills/agentkit-codex-handoff/scripts/upload_current_dir.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +exec python3 "$script_dir/upload_project.py" "$@" diff --git a/frontend/skills/agentkit-codex-handoff/scripts/upload_project.py b/frontend/skills/agentkit-codex-handoff/scripts/upload_project.py new file mode 100755 index 00000000..94812e72 --- /dev/null +++ b/frontend/skills/agentkit-codex-handoff/scripts/upload_project.py @@ -0,0 +1,1776 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Hand off a local Git project and continue its task in Studio.""" + +from __future__ import annotations + +import argparse +import base64 +import datetime as dt +import hashlib +import http.client +import io +import json +import mimetypes +import os +import re +import shutil +import ssl +import stat +import subprocess +import sys +import tarfile +import tempfile +import time +import uuid +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +EXCLUDED_DIRS = { + ".git", + ".cache", + ".mypy_cache", + ".nox", + ".nuxt", + ".parcel-cache", + ".pytest_cache", + ".ruff_cache", + ".svelte-kit", + ".tox", + ".turbo", + ".vite", + "__pycache__", + "coverage", + "node_modules/.cache", +} +EXCLUDED_FILES = {".DS_Store", ".coverage"} +EXCLUDED_SUFFIXES = {".log", ".pyc", ".pyo"} +HIGH_RISK_NAMES = { + ".env", + ".npmrc", + ".pypirc", + "credentials.json", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "id_rsa", + "service-account.json", +} +HIGH_RISK_SUFFIXES = {".key", ".p12", ".pfx", ".pem"} +SECRET_ASSIGNMENT = re.compile( + rb"(?i)(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|password)" + rb"\s*[:=]\s*['\"]?[A-Za-z0-9/+_.-]{12,}" +) +MAX_SCAN_BYTES = 1024 * 1024 +MAX_BUNDLE_BYTES = 512 * 1024 * 1024 +UPLOAD_CHUNK_BYTES = 64 * 1024 * 1024 +UPLOAD_CONNECT_ATTEMPTS = 24 +UPLOAD_RETRY_DELAY_SECONDS = 5.0 +SESSION_CREATE_ATTEMPTS = 6 +SESSION_CREATE_RETRY_DELAY_SECONDS = 2.0 +AGENT_NAME_MAX_CHARACTERS = 12 +MAX_HISTORY_MESSAGES = 100 +MAX_HISTORY_MESSAGE_CHARACTERS = 20_000 +MAX_HISTORY_CHARACTERS = 100_000 +MAX_HISTORY_IMAGES = 10 +MAX_HISTORY_IMAGE_BYTES = 4 * 1024 * 1024 +MAX_HISTORY_IMAGE_TOTAL_BYTES = 8 * 1024 * 1024 +MAX_CONTINUATION_MESSAGE_CHARACTERS = 20_000 +MAX_EVENT_STREAM_LINE_BYTES = 1024 * 1024 +MAX_EVENT_STREAM_DATA_BYTES = 4 * 1024 * 1024 +PAIRING_CODE_PATTERN = re.compile(r"[2-9A-HJ-KM-NP-Z]{4}-?[2-9A-HJ-KM-NP-Z]{4}") +AMBIENT_BROWSER_CONTEXT = re.compile( + r"]*>.*?", + re.DOTALL, +) +LOCAL_IMAGE_MARKDOWN = re.compile( + r"!\[(?P[^\]\n]*)\]\((?:<(?P/[^>\n]+)>|(?P/[^)\n]+))\)" +) +IMAGE_MIME_SIGNATURES = ( + ("image/png", lambda data: data.startswith(b"\x89PNG\r\n\x1a\n")), + ("image/jpeg", lambda data: data.startswith(b"\xff\xd8\xff")), + ( + "image/gif", + lambda data: data.startswith((b"GIF87a", b"GIF89a")), + ), + ( + "image/webp", + lambda data: len(data) >= 12 + and data.startswith(b"RIFF") + and data[8:12] == b"WEBP", + ), +) + + +class HandoffError(RuntimeError): + """A user-safe project handoff failure.""" + + +class RetryableHandoffError(HandoffError): + """A transient request failure that is safe to retry with an idempotency key.""" + + +def report_progress(message: str) -> None: + """Emit a flush-safe progress line for the invoking Codex task.""" + print(f"[handoff] progress: {message}", flush=True) + + +@dataclass(frozen=True) +class ProjectState: + repo: Path + files: tuple[Path, ...] + approximate_bytes: int + git: bool + branch: str + head: str + remote: str + github_remote: bool + status: str + sensitive_paths: tuple[str, ...] + content_warnings: tuple[str, ...] + + +def run_git( + repo: Path, *args: str, check: bool = False +) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + ["git", "-C", str(repo), *args], + check=check, + capture_output=True, + ) + + +def git_text(repo: Path, *args: str) -> str: + result = run_git(repo, *args) + if result.returncode != 0: + return "" + return result.stdout.decode("utf-8", errors="replace").strip() + + +def excluded(relative: str) -> bool: + parts = PurePosixPath(relative).parts + if not parts or parts[0] == ".git": + return True + if any(part in EXCLUDED_DIRS for part in parts): + return True + if any( + "/".join(parts[index : index + 2]) in EXCLUDED_DIRS + for index in range(len(parts) - 1) + ): + return True + name = parts[-1] + return ( + name in EXCLUDED_FILES + or name.startswith("._") + or any(name.endswith(suffix) for suffix in EXCLUDED_SUFFIXES) + ) + + +def project_files(repo: Path, is_git: bool) -> tuple[Path, ...]: + tracked_paths: set[str] = set() + untracked_paths: set[str] = set() + if is_git: + try: + tracked = run_git( + repo, + "ls-files", + "-z", + "--cached", + check=True, + ) + untracked = run_git( + repo, + "ls-files", + "-z", + "--others", + "--exclude-standard", + check=True, + ) + except subprocess.CalledProcessError as error: + raise HandoffError("cannot enumerate the Git working tree") from error + for raw in tracked.stdout.split(b"\0"): + if raw: + tracked_paths.add(os.fsdecode(raw)) + for raw in untracked.stdout.split(b"\0"): + if raw: + untracked_paths.add(os.fsdecode(raw)) + else: + for root, dirnames, filenames in os.walk(repo): + root_path = Path(root) + dirnames[:] = [ + name + for name in dirnames + if not excluded((root_path / name).relative_to(repo).as_posix()) + ] + for name in filenames: + untracked_paths.add((root_path / name).relative_to(repo).as_posix()) + + output: list[Path] = [] + for relative in sorted(tracked_paths | untracked_paths): + if relative not in tracked_paths and excluded(relative): + continue + candidate = repo / relative + if candidate.exists() or candidate.is_symlink(): + output.append(candidate) + return tuple(output) + + +def is_high_risk(relative: str) -> bool: + name = PurePosixPath(relative).name.lower() + if name in HIGH_RISK_NAMES or any( + name.endswith(suffix) for suffix in HIGH_RISK_SUFFIXES + ): + return True + return name.startswith(".env.") and name not in { + ".env.example", + ".env.sample", + ".env.template", + } + + +def content_has_secret(path: Path) -> bool: + try: + if ( + path.is_symlink() + or not path.is_file() + or path.stat().st_size > MAX_SCAN_BYTES + ): + return False + data = path.read_bytes() + except OSError: + return False + if b"\0" in data[:8192]: + return False + return SECRET_ASSIGNMENT.search(data) is not None + + +def sanitize_remote(value: str) -> str: + if not value: + return "" + parsed = urlsplit(value) + if parsed.scheme in {"http", "https"} and parsed.hostname: + host = parsed.hostname + if parsed.port: + host = f"{host}:{parsed.port}" + return urlunsplit((parsed.scheme, host, parsed.path, "", "")) + return value + + +def is_github_remote(value: str) -> bool: + lowered = value.lower() + if lowered.startswith(("git@github.com:", "ssh://git@github.com/")): + return True + parsed = urlsplit(value) + return parsed.hostname == "github.com" + + +def github_https_remote(value: str) -> str: + if value.startswith("git@github.com:"): + return "https://github.com/" + value.split(":", 1)[1] + if value.startswith("ssh://git@github.com/"): + return "https://github.com/" + value.split("ssh://git@github.com/", 1)[1] + return value + + +def github_token() -> str: + if shutil.which("gh") is None: + raise HandoffError( + "GitHub remote requires the gh CLI and an authenticated account" + ) + result = subprocess.run( + ["gh", "auth", "token", "--hostname", "github.com"], + check=False, + capture_output=True, + text=True, + ) + token = result.stdout.strip() + if result.returncode != 0 or not token: + raise HandoffError( + "GitHub remote requires local authentication; run gh auth login first" + ) + return token + + +def inspect_project(repo: Path) -> ProjectState: + repo = repo.expanduser().resolve() + if not repo.is_dir(): + raise HandoffError(f"repository directory does not exist: {repo}") + is_git = run_git(repo, "rev-parse", "--is-inside-work-tree").returncode == 0 + files = project_files(repo, is_git) + approximate_bytes = 0 + sensitive: list[str] = [] + content_warnings: list[str] = [] + for path in files: + relative = path.relative_to(repo).as_posix() + try: + approximate_bytes += path.lstat().st_size + except OSError: + pass + if is_high_risk(relative): + sensitive.append(relative) + elif content_has_secret(path): + content_warnings.append(relative) + status = ( + git_text(repo, "status", "--short", "--branch") + if is_git + else "not a Git repository" + ) + remote = ( + sanitize_remote(git_text(repo, "config", "--get", "remote.origin.url")) + if is_git + else "" + ) + return ProjectState( + repo=repo, + files=files, + approximate_bytes=approximate_bytes, + git=is_git, + branch=git_text(repo, "branch", "--show-current") if is_git else "", + head=git_text(repo, "rev-parse", "HEAD") if is_git else "", + remote=remote, + github_remote=is_github_remote(remote), + status=status, + sensitive_paths=tuple(sensitive), + content_warnings=tuple(content_warnings), + ) + + +def generated_handoff(state: ProjectState, supplied: Path | None) -> bytes: + existing = b"" + source = supplied + if source is None: + candidate = state.repo / "HANDOFF.md" + source = candidate if candidate.is_file() else None + if source is not None: + try: + existing = source.expanduser().read_bytes().rstrip() + b"\n\n" + except OSError as error: + raise HandoffError(f"cannot read handoff file: {source}") from error + timestamp = ( + dt.datetime.now(dt.timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + status = state.status or "clean" + text = f"""# Project Continuation Context + +Generated: {timestamp} + +> Generated transfer metadata. Do not commit this file or this generated section. + +## Source state + +- Project: {state.repo.name} +- Branch: {state.branch or "detached or unavailable"} +- HEAD: {state.head or "unavailable"} +- Remote: {state.remote or "unavailable"} + +```text +{status} +``` + +## Transfer note + +This snapshot contains tracked and non-ignored project files plus working-tree changes. Repository `AGENTS.md` files are ordinary project files and remain in place. System or developer prompts, reasoning, tool logs, databases, global configuration, local extensions, and SSH keys were not transferred. Sanitized user-visible conversation history and GitHub authentication, when enabled, travel through separate ephemeral payloads and are not embedded in this snapshot. +""" + return existing + text.encode("utf-8") + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def safe_symlink(path: Path, repo: Path) -> bool: + if not path.is_symlink(): + return True + target = Path(os.readlink(path)) + if target.is_absolute(): + return False + resolved = (path.parent / target).resolve(strict=False) + return resolved == repo or repo in resolved.parents + + +def add_project_archive(state: ProjectState, destination: Path, handoff: bytes) -> None: + with tarfile.open(destination, "w:gz", format=tarfile.PAX_FORMAT) as archive: + for path in state.files: + relative = path.relative_to(state.repo).as_posix() + if relative == "HANDOFF.md": + continue + if not safe_symlink(path, state.repo): + raise HandoffError(f"refusing to archive external symlink: {relative}") + archive.add(path, arcname=relative, recursive=False) + info = tarfile.TarInfo("HANDOFF.md") + info.size = len(handoff) + info.mode = 0o644 + info.mtime = int(dt.datetime.now(dt.timezone.utc).timestamp()) + archive.addfile(info, io.BytesIO(handoff)) + + +RESTORE_SCRIPT = r"""#!/usr/bin/env python3 +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import tarfile +from pathlib import Path + +def digest(path): + value = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + +def safe_extract(archive_path, destination): + destination = destination.resolve() + with tarfile.open(archive_path, "r:gz") as archive: + members = archive.getmembers() + for member in members: + target = (destination / member.name).resolve() + if destination != target and destination not in target.parents: + raise RuntimeError(f"unsafe archive member: {member.name}") + if member.isdev(): + raise RuntimeError(f"unsupported archive member: {member.name}") + if member.issym() or member.islnk(): + link = Path(member.linkname) + if link.is_absolute(): + raise RuntimeError(f"unsafe archive link: {member.name}") + resolved_link = (target.parent / link).resolve() + if destination != resolved_link and destination not in resolved_link.parents: + raise RuntimeError(f"unsafe archive link: {member.name}") + archive.extractall(destination, members=members) + return len(members) + +def hide_generated_handoff(repo): + git_dir = repo / ".git" + handoff = repo / "HANDOFF.md" + if not git_dir.is_dir() or not handoff.is_file(): + return "none" + tracked = subprocess.run( + ["git", "-C", str(repo), "ls-files", "--error-unmatch", "--", "HANDOFF.md"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if tracked.returncode == 0: + subprocess.run( + ["git", "-C", str(repo), "update-index", "--skip-worktree", "--", "HANDOFF.md"], + check=True, + ) + return "skip-worktree" + exclude = git_dir / "info" / "exclude" + exclude.parent.mkdir(parents=True, exist_ok=True) + existing = exclude.read_text(encoding="utf-8") if exclude.is_file() else "" + if "/HANDOFF.md" not in {line.strip() for line in existing.splitlines()}: + with exclude.open("a", encoding="utf-8") as handle: + if existing and not existing.endswith("\n"): + handle.write("\n") + handle.write("/HANDOFF.md\n") + return "excluded" + +def install_github_credentials(source, repo): + if source is None: + return False + source = Path(source) + try: + value = json.loads(source.read_text(encoding="utf-8")) + if value.get("host") != "github.com": + raise RuntimeError("unsupported GitHub credential host") + username = value.get("username") or "x-access-token" + token = value.get("token") or "" + if not token: + raise RuntimeError("GitHub credential payload is empty") + + helper_dir = Path.home() / ".config" / "agentkit-studio" + helper_dir.mkdir(parents=True, exist_ok=True) + token_path = helper_dir / "github-token" + token_path.write_text(token + "\n", encoding="utf-8") + token_path.chmod(0o600) + helper_path = helper_dir / "github-credential-helper.sh" + helper_path.write_text( + "#!/bin/sh\n" + "if [ \"${1:-}\" = get ]; then\n" + f" printf '%s\\n' 'username={username}'\n" + " printf 'password='\n" + f" cat '{token_path}'\n" + "fi\n", + encoding="utf-8", + ) + helper_path.chmod(0o700) + + gh_dir = Path.home() / ".config" / "gh" + gh_dir.mkdir(parents=True, exist_ok=True) + hosts = gh_dir / "hosts.yml" + hosts.write_text( + "github.com:\n" + " git_protocol: https\n" + " users:\n" + f" {username}:\n" + f" user: {username}\n" + f" oauth_token: {token}\n", + encoding="utf-8", + ) + hosts.chmod(0o600) + if (repo / ".git").exists(): + subprocess.run( + [ + "git", + "-C", + str(repo), + "config", + "credential.https://github.com.helper", + f"!{helper_path}", + ], + check=True, + ) + return True + finally: + source.unlink(missing_ok=True) + +parser = argparse.ArgumentParser() +parser.add_argument("--stage", required=True) +parser.add_argument("--repo", required=True) +parser.add_argument("--github-credentials") +args = parser.parse_args() +stage = Path(args.stage).resolve() +repo = Path(args.repo).resolve() +if len(repo.parts) < 3 or str(repo) in {"/", "/home", "/home/gem", "/workspace"}: + raise RuntimeError("refusing unsafe remote repository path") +manifest = json.loads((stage / "manifest.json").read_text(encoding="utf-8")) +project_archive = stage / "project.tar.gz" +if digest(project_archive) != manifest["artifacts"]["project_sha256"]: + raise RuntimeError("project archive checksum mismatch") + +bundle = stage / "repo.git.bundle" +if repo.is_symlink(): + repo.unlink() +elif repo.exists(): + shutil.rmtree(repo) +repo.parent.mkdir(parents=True, exist_ok=True) +if bundle.is_file() and shutil.which("git"): + subprocess.run(["git", "clone", "--quiet", str(bundle), str(repo)], check=True) + branch = manifest["git"].get("branch") or "" + head = manifest["git"].get("head") or "" + if branch: + checkout = subprocess.run( + ["git", "-C", str(repo), "checkout", "--quiet", branch], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if checkout.returncode != 0 and head: + subprocess.run( + ["git", "-C", str(repo), "checkout", "--quiet", "--detach", head], + check=True, + ) + for child in repo.iterdir(): + if child.name == ".git": + continue + if child.is_dir() and not child.is_symlink(): + shutil.rmtree(child) + else: + child.unlink() +else: + repo.mkdir(parents=True) + +file_count = safe_extract(project_archive, repo) +remote = manifest["git"].get("remote") or "" +if remote and (repo / ".git").exists(): + subprocess.run( + ["git", "-C", str(repo), "remote", "set-url", "origin", remote], check=False + ) +handoff_git_policy = hide_generated_handoff(repo) +github_auth = install_github_credentials(args.github_credentials, repo) +status = "" +if (repo / ".git").exists(): + result = subprocess.run( + ["git", "-C", str(repo), "status", "--short", "--branch"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + status = result.stdout.strip() +print( + json.dumps( + { + "restored": True, + "repo": str(repo), + "fileCount": file_count, + "gitStatus": status, + "githubAuth": github_auth, + "handoffGitPolicy": handoff_git_policy, + }, + ensure_ascii=False, + ) +) +""" + + +def create_git_bundle(state: ProjectState, destination: Path) -> bool: + if not state.git or not state.head: + return False + result = run_git(state.repo, "bundle", "create", str(destination), "--all") + return result.returncode == 0 and destination.is_file() + + +def write_github_credentials(destination: Path, token: str) -> None: + destination.write_text( + json.dumps( + {"host": "github.com", "username": "x-access-token", "token": token}, + separators=(",", ":"), + ), + encoding="utf-8", + ) + destination.chmod(0o600) + + +def build_bundle( + state: ProjectState, + project_name: str, + handoff_file: Path | None, + work: Path, + github_credentials: bool, +) -> Path: + project_archive = work / "project.tar.gz" + add_project_archive(state, project_archive, generated_handoff(state, handoff_file)) + git_bundle = work / "repo.git.bundle" + has_git_bundle = create_git_bundle(state, git_bundle) + file_count = len(state.files) - int((state.repo / "HANDOFF.md") in state.files) + 1 + manifest = { + "schemaVersion": 1, + "kind": "studio-project-handoff", + "projectName": project_name, + "createdAt": ( + dt.datetime.now(dt.timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ), + "git": { + "branch": state.branch, + "head": state.head, + "remote": ( + github_https_remote(state.remote) + if github_credentials and state.github_remote + else state.remote + ), + }, + "transfer": { + "fileCount": file_count, + "approximateSourceBytes": state.approximate_bytes, + "includes": [ + "tracked files", + "non-ignored untracked files", + "working-tree changes", + "AGENTS.md", + "HANDOFF.md", + ], + "excludes": [ + "ignored files", + "Codex runtime state", + "global Skills", + "SSH keys", + "global configuration", + ], + "githubCredentials": "ephemeral separate payload" + if github_credentials + else "not transferred", + }, + "artifacts": { + "project_sha256": sha256(project_archive), + "gitBundle": has_git_bundle, + }, + } + (work / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + (work / "restore_project.py").write_text(RESTORE_SCRIPT, encoding="utf-8") + bundle = work / f"{slug(project_name)}-handoff.tar.gz" + with tarfile.open(bundle, "w:gz", format=tarfile.PAX_FORMAT) as archive: + for name in ("manifest.json", "project.tar.gz", "restore_project.py"): + archive.add(work / name, arcname=name, recursive=False) + if has_git_bundle: + archive.add(git_bundle, arcname="repo.git.bundle", recursive=False) + if bundle.stat().st_size > MAX_BUNDLE_BYTES: + raise HandoffError( + f"handoff bundle exceeds {MAX_BUNDLE_BYTES // (1024 * 1024)} MiB" + ) + return bundle + + +def slug(value: str) -> str: + output = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-.") + return output[:80] or "project" + + +def agent_name(value: str) -> str: + cleaned = re.sub(r"\s+", " ", value.strip()) + if not cleaned: + raise HandoffError( + "--agent-name is required; let Codex create a concise task description" + ) + if len(cleaned) > AGENT_NAME_MAX_CHARACTERS: + raise HandoffError( + f"--agent-name must be at most {AGENT_NAME_MAX_CHARACTERS} characters" + ) + if any(ord(character) < 32 or ord(character) == 127 for character in cleaned): + raise HandoffError("--agent-name contains unsupported control characters") + return cleaned + + +def visible_message(value: str) -> str: + cleaned = AMBIENT_BROWSER_CONTEXT.sub("", value).strip() + marker = "## My request:" + if marker in cleaned: + cleaned = cleaned.rsplit(marker, 1)[1].strip() + return cleaned + + +def _history_image(path: Path, alt: str) -> tuple[dict[str, str], int]: + path = path.expanduser() + if not path.is_absolute(): + raise HandoffError("conversation history image path must be absolute") + try: + descriptor = os.open( + path, + os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), + ) + except OSError as error: + if path.is_symlink(): + raise HandoffError( + "conversation history image must not be a symbolic link" + ) from error + raise HandoffError("cannot read conversation history image") from error + try: + details = os.fstat(descriptor) + if not stat.S_ISREG(details.st_mode): + raise HandoffError("conversation history image is not a regular file") + size = details.st_size + if size <= 0: + raise HandoffError("conversation history image is empty") + if size > MAX_HISTORY_IMAGE_BYTES: + raise HandoffError("conversation history contains an oversized image") + with os.fdopen(descriptor, "rb") as handle: + descriptor = -1 + data = handle.read(MAX_HISTORY_IMAGE_BYTES + 1) + except HandoffError: + raise + except OSError as error: + raise HandoffError("cannot read conversation history image") from error + finally: + if descriptor >= 0: + os.close(descriptor) + if len(data) != size: + raise HandoffError("conversation history image changed while being read") + mime_type = next( + (mime for mime, matches in IMAGE_MIME_SIGNATURES if matches(data)), + "", + ) + if not mime_type: + raise HandoffError("conversation history image format is not supported") + safe_alt = alt.strip()[:500] + return ( + { + "mimeType": mime_type, + "data": base64.b64encode(data).decode("ascii"), + "name": path.name[:255] or "image", + "alt": safe_alt or path.stem[:500] or "图片", + }, + size, + ) + + +def _message_image_paths( + content: str, raw_images: object +) -> tuple[str, list[tuple[Path, str]]]: + images: list[tuple[Path, str]] = [] + seen: set[Path] = set() + if raw_images is not None: + if not isinstance(raw_images, list): + raise HandoffError("conversation history images must be a list") + for raw_image in raw_images: + if not isinstance(raw_image, dict): + raise HandoffError("conversation history contains an invalid image") + raw_path = raw_image.get("path") + raw_alt = raw_image.get("alt", "") + if not isinstance(raw_path, str) or not isinstance(raw_alt, str): + raise HandoffError("conversation history contains an invalid image") + path = Path(raw_path).expanduser() + if path not in seen: + images.append((path, raw_alt)) + seen.add(path) + + def collect(match: re.Match[str]) -> str: + path = Path(match.group("bracketed") or match.group("plain")).expanduser() + if path not in seen: + images.append((path, match.group("alt"))) + seen.add(path) + return "" + + cleaned = LOCAL_IMAGE_MARKDOWN.sub(collect, content) + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip() + return cleaned, images + + +def conversation_history(source: Path | None) -> list[dict[str, Any]]: + if source is None: + raise HandoffError("--history is required for a conversation handoff") + try: + raw = source.expanduser().read_text(encoding="utf-8") + except OSError as error: + raise HandoffError(f"cannot read conversation history: {source}") from error + try: + value = json.loads(raw) + except json.JSONDecodeError as error: + raise HandoffError("conversation history is not valid JSON") from error + if not isinstance(value, dict) or value.get("schemaVersion") not in {1, 2}: + raise HandoffError("conversation history must use schemaVersion 1 or 2") + raw_messages = value.get("messages") + if not isinstance(raw_messages, list) or not raw_messages: + raise HandoffError("conversation history must contain visible messages") + if len(raw_messages) > MAX_HISTORY_MESSAGES: + raise HandoffError( + f"conversation history exceeds {MAX_HISTORY_MESSAGES} messages" + ) + messages: list[dict[str, Any]] = [] + total_characters = 0 + total_image_bytes = 0 + total_images = 0 + for item in raw_messages: + if not isinstance(item, dict): + raise HandoffError("conversation history contains an invalid message") + role = item.get("role") + content = item.get("content") + if role not in {"user", "assistant"} or not isinstance(content, str): + raise HandoffError( + "conversation history may contain only user and assistant messages" + ) + content, image_paths = _message_image_paths( + visible_message(content), item.get("images") + ) + if role == "assistant" and image_paths: + raise HandoffError( + "conversation history images are supported only for user messages" + ) + images: list[dict[str, str]] = [] + for image_path, image_alt in image_paths: + total_images += 1 + if total_images > MAX_HISTORY_IMAGES: + raise HandoffError("conversation history contains too many images") + image, image_bytes = _history_image(image_path, image_alt) + total_image_bytes += image_bytes + if total_image_bytes > MAX_HISTORY_IMAGE_TOTAL_BYTES: + raise HandoffError("conversation history images are too large") + images.append(image) + if not content and not images: + continue + if len(content) > MAX_HISTORY_MESSAGE_CHARACTERS: + raise HandoffError("conversation history contains an oversized message") + total_characters += len(content) + if total_characters > MAX_HISTORY_CHARACTERS: + raise HandoffError("conversation history is too large") + messages.append( + { + "role": role, + "content": content, + **({"images": images} if images else {}), + } + ) + if not messages: + raise HandoffError("conversation history contains no visible messages") + return messages + + +def continuation_message(value: str) -> str: + message = value.strip() or "继续" + if len(message) > MAX_CONTINUATION_MESSAGE_CHARACTERS: + raise HandoffError("--continue-message is too large") + if "\0" in message: + raise HandoffError("--continue-message contains unsupported characters") + return message + + +def studio_url(value: str) -> str: + parsed = urlsplit(value.strip()) + try: + _ = parsed.port + except ValueError as error: + raise HandoffError("--studio-url contains an invalid port") from error + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise HandoffError("--studio-url must be an HTTP(S) origin or base path") + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")) + + +def pairing_code(value: str) -> str: + normalized = value.strip().upper() + if not PAIRING_CODE_PATTERN.fullmatch(normalized): + raise HandoffError("the one-time pairing code has an invalid format") + compact = normalized.replace("-", "") + return f"{compact[:4]}-{compact[4:]}" + + +def stable_handoff_id( + code: str, + repo: Path, + project_name: str, + remote_home: str, +) -> str: + """Return a retry-stable, non-reversible request identifier.""" + identity = json.dumps( + { + "pairingCode": pairing_code(code), + "repo": str(repo.expanduser().resolve()), + "projectName": project_name, + "remoteHome": remote_home.rstrip("/") or "/", + }, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(identity).hexdigest()[:32] + + +def service_url(endpoint: str, route: str) -> str: + parsed = urlsplit(endpoint) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise HandoffError("Studio returned an invalid Sandbox endpoint") + path = parsed.path.rstrip("/") + route + return urlunsplit((parsed.scheme, parsed.netloc, path, parsed.query, "")) + + +def tls_context() -> ssl.SSLContext: + """Build a verified TLS context that also trusts the macOS keychain.""" + context = ssl.create_default_context() + if sys.platform != "darwin": + return context + security = shutil.which("security") + if not security: + return context + certificate_data = bytearray() + for keychain in ( + "", + "/Library/Keychains/System.keychain", + "/System/Library/Keychains/SystemRootCertificates.keychain", + ): + command = [security, "find-certificate", "-a", "-p"] + if keychain: + command.append(keychain) + certificates = subprocess.run( + command, + capture_output=True, + check=False, + ) + if certificates.returncode == 0: + certificate_data.extend(certificates.stdout) + if b"-----BEGIN CERTIFICATE-----" in certificate_data: + context.load_verify_locations( + cadata=certificate_data.decode("utf-8", errors="replace") + ) + return context + + +def connection(url: str) -> tuple[http.client.HTTPConnection, str]: + parsed = urlsplit(url) + host = parsed.hostname + if not host: + raise HandoffError("invalid service URL") + if parsed.scheme == "https": + client: http.client.HTTPConnection = http.client.HTTPSConnection( + host, + parsed.port or 443, + timeout=330, + context=tls_context(), + ) + elif parsed.scheme == "http": + client = http.client.HTTPConnection(host, parsed.port or 80, timeout=330) + else: + raise HandoffError("service URL must use http or https") + target = parsed.path or "/" + if parsed.query: + target += "?" + parsed.query + return client, target + + +def response_json(response: http.client.HTTPResponse, action: str) -> dict[str, Any]: + raw = response.read() + try: + value = json.loads(raw.decode("utf-8", errors="replace")) if raw else {} + except json.JSONDecodeError as error: + raise HandoffError( + f"{action} returned an invalid response (HTTP {response.status})" + ) from error + if response.status < 200 or response.status >= 300: + detail = value.get("detail") if isinstance(value, dict) else None + retryable = False + if isinstance(detail, dict): + retryable = detail.get("retryable") is True + detail = detail.get("message") or detail.get("code") + message = detail if isinstance(detail, str) else f"HTTP {response.status}" + error_type = RetryableHandoffError if retryable else HandoffError + raise error_type(f"{action} failed: {message}") + if not isinstance(value, dict): + raise HandoffError(f"{action} returned an invalid response") + return value + + +def post_json( + url: str, + payload: dict[str, Any], + action: str, + *, + attempts: int = 1, + retry_delay_seconds: float = SESSION_CREATE_RETRY_DELAY_SECONDS, +) -> dict[str, Any]: + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) + last_error: BaseException | None = None + for attempt in range(1, max(1, attempts) + 1): + client, target = connection(url) + try: + client.request( + "POST", + target, + body=body, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Content-Length": str(len(body)), + }, + ) + return response_json(client.getresponse(), action) + except ssl.SSLCertVerificationError as error: + raise HandoffError( + f"{action} could not verify the service TLS certificate" + ) from error + except (OSError, RetryableHandoffError) as error: + last_error = error + finally: + client.close() + if attempt < attempts: + time.sleep(retry_delay_seconds) + if isinstance(last_error, RetryableHandoffError): + raise last_error + raise HandoffError(f"{action} could not connect to the service") from last_error + + +def post_event_stream(url: str, payload: dict[str, Any], action: str) -> None: + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) + client, target = connection(url) + try: + client.request( + "POST", + target, + body=body, + headers={ + "Accept": "text/event-stream", + "Content-Type": "application/json", + "Content-Length": str(len(body)), + }, + ) + response = client.getresponse() + if response.status < 200 or response.status >= 300: + response_json(response, action) + event_name = "" + data_lines: list[str] = [] + data_bytes = 0 + saw_done = False + while raw_line := response.readline(): + if len(raw_line) > MAX_EVENT_STREAM_LINE_BYTES: + raise HandoffError(f"{action} returned an oversized event") + line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") + if not line: + data_text = "\n".join(data_lines) + if event_name == "error": + try: + error_payload = json.loads(data_text) if data_text else {} + except json.JSONDecodeError: + error_payload = {} + detail = error_payload.get("message") + if not isinstance(detail, str) or not detail: + detail = "the cloud continuation failed" + raise HandoffError(f"{action} failed: {detail}") + if event_name == "progress": + try: + progress_payload = json.loads(data_text) if data_text else {} + except json.JSONDecodeError: + progress_payload = {} + progress_message = progress_payload.get("message") + if isinstance(progress_message, str) and progress_message.strip(): + report_progress(progress_message.strip()) + if event_name == "done": + try: + done_payload = json.loads(data_text) if data_text else {} + except json.JSONDecodeError as error: + raise HandoffError( + f"{action} returned an invalid completion event" + ) from error + if ( + isinstance(done_payload, dict) + and done_payload.get("reason") == "failed" + ): + raise HandoffError(f"{action} failed in Studio") + saw_done = True + break + event_name = "" + data_lines = [] + data_bytes = 0 + continue + if line.startswith("event:"): + event_name = line[6:].strip() + elif line.startswith("data:"): + data_line = line[5:].lstrip() + data_bytes += len(data_line.encode("utf-8")) + if data_bytes > MAX_EVENT_STREAM_DATA_BYTES: + raise HandoffError(f"{action} returned an oversized event") + data_lines.append(data_line) + if not saw_done: + raise HandoffError(f"{action} ended before Studio confirmed completion") + except OSError as error: + raise HandoffError(f"{action} could not connect to the service") from error + finally: + client.close() + + +def continue_in_studio( + studio_url: str, + session_id: str, + pairing_code: str, + history: list[dict[str, Any]], + message: str, +) -> None: + post_event_stream( + studio_url.rstrip("/") + + f"/web/sandbox/codex-project-handoff/sessions/{session_id}/messages", + { + "pairingCode": pairing_code, + "history": history, + "message": message, + }, + "Studio cloud continuation", + ) + + +def upload_file(endpoint: str, source: Path, destination: str) -> dict[str, Any]: + url = service_url(endpoint, "/v1/file/upload") + name = source.name.replace('"', "") + last_error: OSError | None = None + for attempt in range(1, UPLOAD_CONNECT_ATTEMPTS + 1): + boundary = "----studio-handoff-" + uuid.uuid4().hex + preamble = ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="path"\r\n\r\n' + f"{destination}\r\n" + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n' + f"Content-Type: " + f"{mimetypes.guess_type(name)[0] or 'application/octet-stream'}" + "\r\n\r\n" + ).encode() + ending = f"\r\n--{boundary}--\r\n".encode("ascii") + client, target = connection(url) + try: + client.putrequest("POST", target) + client.putheader("Accept", "application/json") + client.putheader( + "Content-Type", f"multipart/form-data; boundary={boundary}" + ) + client.putheader( + "Content-Length", + str(len(preamble) + source.stat().st_size + len(ending)), + ) + client.endheaders() + client.send(preamble) + with source.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + client.send(chunk) + client.send(ending) + value = response_json(client.getresponse(), "Sandbox upload") + if value.get("success") is False: + raise HandoffError( + f"Sandbox upload failed: {value.get('error') or 'unknown error'}" + ) + return value + except ssl.SSLCertVerificationError as error: + raise HandoffError( + "Sandbox upload could not verify the service TLS certificate" + ) from error + except OSError as error: + last_error = error + finally: + client.close() + if attempt < UPLOAD_CONNECT_ATTEMPTS: + time.sleep(UPLOAD_RETRY_DELAY_SECONDS) + raise HandoffError( + "Sandbox upload could not connect to the service" + ) from last_error + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def shell_script_command(lines: Iterable[str]) -> str: + """Wrap a multi-line script for Sandbox terminals that accept one command.""" + import shlex + + script = "\n".join(lines) + return f"sh -lc {shlex.quote(script)}" + + +def remote_reassemble_bundle( + endpoint: str, + destination: str, + part_paths: tuple[str, ...], + expected_sha256: str, +) -> None: + import shlex + + if not part_paths: + raise HandoffError("Sandbox upload produced no bundle parts") + quoted_parts = " ".join(shlex.quote(path) for path in part_paths) + command = shell_script_command( + [ + "set -eu", + f"bundle={shlex.quote(destination)}", + f"expected_sha256={shlex.quote(expected_sha256)}", + 'temporary="${bundle}.assembling"', + 'cleanup() { rm -f -- "$temporary"; }', + "trap cleanup EXIT", + 'rm -f -- "$temporary"', + f'cat -- {quoted_parts} > "$temporary"', + 'actual_sha256="$(sha256sum "$temporary" | awk \'{print $1}\')"', + 'if [ "$actual_sha256" != "$expected_sha256" ]; then', + ' echo "bundle checksum mismatch" >&2', + " exit 1", + "fi", + 'mv -f -- "$temporary" "$bundle"', + *(f"rm -f -- {shlex.quote(path)}" for path in part_paths), + ] + ) + value = post_json( + service_url(endpoint, "/v1/shell/exec"), + {"id": "", "exec_dir": "/", "command": command}, + "Sandbox bundle assembly", + ) + status = nested( + value, + ( + ("data", "status"), + ("data", "Status"), + ("status",), + ("Status",), + ("Result", "data", "status"), + ("Result", "status"), + ), + ) + exit_code = nested( + value, + ( + ("data", "exit_code"), + ("data", "ExitCode"), + ("exit_code",), + ("ExitCode",), + ("Result", "data", "exit_code"), + ("Result", "exit_code"), + ), + ) + if ( + value.get("success") is False + or status in {"failed", "error"} + or exit_code not in (None, 0, "0") + ): + raise HandoffError("Sandbox bundle assembly failed") + + +def upload_project_bundle(endpoint: str, source: Path, destination: str) -> None: + """Upload a project bundle, using bounded requests for large archives.""" + size = source.stat().st_size + if size <= UPLOAD_CHUNK_BYTES: + upload_file(endpoint, source, destination) + return + + part_count = (size + UPLOAD_CHUNK_BYTES - 1) // UPLOAD_CHUNK_BYTES + remote_parts = tuple( + f"{destination}.part-{index:04d}" for index in range(part_count) + ) + assembled = False + try: + with tempfile.TemporaryDirectory( + prefix="studio-project-handoff-parts-", + dir=source.parent, + ) as temporary: + parts_dir = Path(temporary) + with source.open("rb") as handle: + for index, remote_part in enumerate(remote_parts): + part = parts_dir / f"bundle.part-{index:04d}" + part.write_bytes(handle.read(UPLOAD_CHUNK_BYTES)) + report_progress(f"正在上传项目({index + 1}/{part_count})") + upload_file(endpoint, part, remote_part) + remote_reassemble_bundle( + endpoint, + destination, + remote_parts, + file_sha256(source), + ) + assembled = True + finally: + if not assembled: + try: + cleanup_remote_artifacts(endpoint, (destination, *remote_parts)) + except HandoffError: + pass + + +def report_handoff_failure( + studio_base_url: str, + session_id: str, + code: str, + handoff_id: str, + failed_stage: str, + error: HandoffError, +) -> None: + """Best-effort synchronization of an upload-side failure to Studio.""" + try: + post_json( + studio_base_url.rstrip("/") + + f"/web/sandbox/codex-project-handoff/sessions/{session_id}/status", + { + "pairingCode": code, + "handoffId": handoff_id, + "failedStage": failed_stage, + "error": str(error), + }, + "Studio handoff status update", + ) + except HandoffError: + pass + + +def nested(value: dict[str, Any], paths: Iterable[tuple[str, ...]]) -> Any: + for path in paths: + current: Any = value + for key in path: + if not isinstance(current, dict) or key not in current: + break + current = current[key] + else: + return current + return None + + +def remote_restore( + endpoint: str, + bundle_path: str, + stage: str, + repo: str, + github_credentials_path: str | None, +) -> dict[str, Any]: + import shlex + + command = shell_script_command( + [ + "set -eu", + f"bundle={shlex.quote(bundle_path)}", + f"stage={shlex.quote(stage)}", + f"repo={shlex.quote(repo)}", + f"github_credentials={shlex.quote(github_credentials_path or '')}", + 'cleanup() { [ -z "$github_credentials" ] || rm -f "$github_credentials"; }', + "trap cleanup EXIT", + 'rm -rf "$stage"', + 'mkdir -p "$stage"', + 'tar -xzf "$bundle" -C "$stage"', + 'restore_output="$stage/restore-output.log"', + "set +e", + 'if [ -n "$github_credentials" ]; then', + ' python3 "$stage/restore_project.py" --stage "$stage" --repo "$repo" --github-credentials "$github_credentials" >"$restore_output" 2>&1 &', + "else", + ' python3 "$stage/restore_project.py" --stage "$stage" --repo "$repo" >"$restore_output" 2>&1 &', + "fi", + "restore_pid=$!", + 'while kill -0 "$restore_pid" 2>/dev/null; do', + " echo '[handoff] restore in progress'", + " sleep 5", + "done", + 'wait "$restore_pid"', + "restore_status=$?", + "set -e", + 'cat "$restore_output"', + 'if [ "$restore_status" -ne 0 ]; then exit "$restore_status"; fi', + 'rm -f "$bundle"', + 'rm -rf "$stage"', + ] + ) + value = post_json( + service_url(endpoint, "/v1/shell/exec"), + {"id": "", "exec_dir": "/", "command": command}, + "Sandbox restore", + ) + status = nested( + value, + ( + ("data", "status"), + ("data", "Status"), + ("status",), + ("Status",), + ("Result", "data", "status"), + ("Result", "status"), + ), + ) + exit_code = nested( + value, + ( + ("data", "exit_code"), + ("data", "ExitCode"), + ("exit_code",), + ("ExitCode",), + ("Result", "data", "exit_code"), + ("Result", "exit_code"), + ), + ) + output = nested( + value, + ( + ("data", "output"), + ("data", "Output"), + ("output",), + ("Output",), + ("Result", "data", "output"), + ("Result", "output"), + ), + ) + if ( + value.get("success") is False + or status in {"failed", "error"} + or exit_code not in (None, 0, "0") + ): + raise HandoffError( + f"Sandbox restore failed: {str(output or status or exit_code)[:500]}" + ) + if not isinstance(output, str): + output = "" + restored: dict[str, Any] = {} + for line in reversed(output.splitlines()): + try: + candidate = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(candidate, dict) and candidate.get("restored") is True: + restored = candidate + break + if not restored: + raise HandoffError("Sandbox restore completed without verification output") + return restored + + +def cleanup_remote_file(endpoint: str, path: str) -> None: + import shlex + + post_json( + service_url(endpoint, "/v1/shell/exec"), + { + "id": "", + "exec_dir": "/", + "command": f"rm -f -- {shlex.quote(path)}", + }, + "Sandbox credential cleanup", + ) + + +def cleanup_remote_artifacts(endpoint: str, paths: Iterable[str]) -> None: + import shlex + + safe_paths = [path for path in paths if path.startswith("/") and path != "/"] + if not safe_paths: + return + command = shell_script_command( + [ + "set -eu", + *(f"rm -rf -- {shlex.quote(path)}" for path in safe_paths), + ] + ) + post_json( + service_url(endpoint, "/v1/shell/exec"), + {"id": "", "exec_dir": "/", "command": command}, + "Sandbox handoff cleanup", + ) + + +def preview(state: ProjectState) -> None: + print(f"[handoff] repo: {state.repo}") + print(f"[handoff] files: {len(state.files)}") + print(f"[handoff] approximate bytes: {state.approximate_bytes}") + print(f"[handoff] branch: {state.branch or 'unavailable'}") + print(f"[handoff] HEAD: {state.head or 'unavailable'}") + if state.github_remote: + try: + token = github_token() + except HandoffError: + print("[handoff] GitHub credentials: missing") + else: + print("[handoff] GitHub credentials: available via gh") + del token + else: + print("[handoff] GitHub credentials: not applicable to this remote") + if state.sensitive_paths: + print("[handoff] high-risk files:") + for path in state.sensitive_paths[:20]: + print(f" - {path}") + if state.content_warnings: + print("[handoff] possible secret assignments (values hidden):") + for path in state.content_warnings[:20]: + print(f" - {path}") + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + result.add_argument("--repo", default=os.getcwd()) + result.add_argument("--studio-url", default=os.getenv("STUDIO_URL", "")) + result.add_argument( + "--pairing-code", + default=os.getenv("AGENTKIT_STUDIO_PAIRING_CODE", ""), + ) + result.add_argument( + "--project-name", default=os.getenv("CODEX_PROJECT_UPLOAD_PROJECT_NAME", "") + ) + result.add_argument( + "--agent-name", + default=os.getenv("CODEX_PROJECT_HANDOFF_AGENT_NAME", ""), + ) + result.add_argument("--remote-home", default="/home/gem") + result.add_argument("--handoff", type=Path) + result.add_argument("--history", type=Path) + result.add_argument( + "--continue-message", + default=os.getenv("CODEX_PROJECT_HANDOFF_CONTINUE_MESSAGE", "继续"), + ) + result.add_argument("--output", type=Path) + result.add_argument("--dry-run", action="store_true") + result.add_argument("--yes", action="store_true") + result.add_argument("--allow-sensitive", action="store_true") + result.add_argument("--no-github-credentials", action="store_true") + return result + + +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) + state = inspect_project(Path(args.repo)) + preview(state) + project_name = args.project_name.strip() or state.repo.name + display_name = agent_name(args.agent_name) + studio_base_url = studio_url(args.studio_url) if args.studio_url.strip() else "" + one_time_code = pairing_code(args.pairing_code) if args.pairing_code.strip() else "" + history = conversation_history(args.history) + continue_message = continuation_message(args.continue_message) + history_images = sum(len(message.get("images", [])) for message in history) + print(f"[handoff] conversation messages: {len(history)}") + print(f"[handoff] conversation images: {history_images}") + github_credentials_enabled = state.github_remote and not args.no_github_credentials + if args.dry_run: + with tempfile.TemporaryDirectory( + prefix="studio-project-handoff-preview-" + ) as temporary: + preview_bundle = build_bundle( + state, + project_name, + args.handoff, + Path(temporary), + github_credentials_enabled, + ) + print(f"[handoff] preview bundle bytes: {preview_bundle.stat().st_size}") + print("[handoff] dry run only; no session was created and nothing was uploaded") + return 0 + if not args.yes: + raise HandoffError("live upload requires --yes after reviewing --dry-run") + if (state.sensitive_paths or state.content_warnings) and not args.allow_sensitive: + raise HandoffError( + "sensitive file warnings require explicit --allow-sensitive approval" + ) + if not args.studio_url.strip() or not args.pairing_code.strip(): + raise HandoffError("--studio-url and a one-time pairing code are required") + + credential_token = github_token() if github_credentials_enabled else "" + + output = args.output.expanduser().resolve() if args.output else None + with tempfile.TemporaryDirectory(prefix="studio-project-handoff-") as temporary: + work = Path(temporary) + bundle = build_bundle( + state, + project_name, + args.handoff, + work, + github_credentials_enabled, + ) + credentials_file: Path | None = None + if github_credentials_enabled: + credentials_file = work / "github-credentials.json" + write_github_credentials(credentials_file, credential_token) + credential_token = "" + if output: + output.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(bundle, output) + print(f"[handoff] retained local bundle: {output}") + print(f"[handoff] bundle bytes: {bundle.stat().st_size}") + handoff_id = stable_handoff_id( + one_time_code, + state.repo, + project_name, + args.remote_home, + ) + report_progress("正在创建云端 Session") + session = post_json( + studio_base_url + "/web/sandbox/codex-project-handoff/sessions", + { + "pairingCode": one_time_code, + "projectName": project_name, + "agentName": display_name, + "remoteHome": args.remote_home, + "handoffId": handoff_id, + }, + "Studio session creation", + attempts=SESSION_CREATE_ATTEMPTS, + ) + endpoint = session.get("endpoint") + remote_repo = session.get("remoteRepoDir") + session_id = session.get("sessionId") + if ( + not isinstance(endpoint, str) + or not isinstance(remote_repo, str) + or not isinstance(session_id, str) + ): + raise HandoffError("Studio created an incomplete Sandbox session") + print( + "[handoff] session created: " + + json.dumps( + { + "displayName": session.get("displayName") or display_name, + "sessionId": session_id, + "remoteRepoDir": remote_repo, + }, + ensure_ascii=False, + ) + ) + report_progress("云端 Session 已创建") + remote_root = args.remote_home.rstrip("/") + remote_bundle = f"{remote_root}/{slug(project_name)}-handoff.tar.gz" + stage = f"{remote_root}/.studio-project-handoff/{slug(project_name)}-restore" + remote_credentials: str | None = None + if credentials_file is not None: + remote_credentials = ( + f"{remote_root}/.studio-github-credentials-{uuid.uuid4().hex}.json" + ) + restore_completed = False + try: + report_progress("正在上传项目") + try: + upload_project_bundle(endpoint, bundle, remote_bundle) + if credentials_file is not None and remote_credentials is not None: + upload_file(endpoint, credentials_file, remote_credentials) + except HandoffError as error: + report_handoff_failure( + studio_base_url, + session_id, + one_time_code, + handoff_id, + "uploading-project", + error, + ) + raise + report_progress("正在恢复项目") + try: + restored = remote_restore( + endpoint, + remote_bundle, + stage, + remote_repo, + remote_credentials, + ) + except HandoffError as error: + report_handoff_failure( + studio_base_url, + session_id, + one_time_code, + handoff_id, + "restoring-project", + error, + ) + raise + restore_completed = True + report_progress("项目恢复完成") + finally: + if not restore_completed: + report_progress("正在清理临时文件") + if remote_credentials: + try: + cleanup_remote_file(endpoint, remote_credentials) + except HandoffError: + pass + try: + cleanup_remote_artifacts(endpoint, (remote_bundle, stage)) + except HandoffError: + pass + result = { + "displayName": session.get("displayName") or display_name, + "sessionId": session_id, + "remoteRepoDir": remote_repo, + "fileCount": restored.get("fileCount"), + "gitStatus": restored.get("gitStatus", ""), + "githubAuth": restored.get("githubAuth", False), + "historyMessages": len(history), + "historyImages": history_images, + "restored": True, + "continued": False, + } + report_progress("正在发送续跑任务") + continue_in_studio( + studio_base_url, + session_id, + one_time_code, + history, + continue_message, + ) + result["continued"] = True + print("[handoff] result: " + json.dumps(result, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except HandoffError as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9b460a7a..b14e0055 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -155,6 +155,7 @@ import { type SandboxPermissions, type SandboxSession as SandboxSessionInfo, type SandboxSkill, + type SandboxThreadSnapshot, type SandboxThreadSummary, type SandboxToolLaunch, } from "./adk/sandbox"; @@ -202,6 +203,7 @@ import { import { SandboxAgentDetails } from "./ui/SandboxAgentDetails"; import { SandboxAgentWorkspace } from "./ui/SandboxAgentWorkspace"; import { SandboxComposer } from "./ui/SandboxComposer"; +import { SandboxProjectUploadDialog } from "./ui/SandboxProjectUploadDialog"; import { sandboxSnapshotTurns } from "./ui/sandboxCommands"; import { useSandboxCodexCommands } from "./ui/useSandboxCodexCommands"; import { StudioConfirmDialog } from "./ui/StudioConfirmDialog"; @@ -253,6 +255,7 @@ interface NewChatCapabilitiesState { builtinTools?: string[]; temporaryEnabled?: boolean; deepseekHarnessEnabled?: boolean; + sandboxEndpointExportEnabled?: boolean; skillCustomizationEnabled?: boolean; } @@ -280,6 +283,9 @@ async function probeNewChatCapabilities( deepseekHarnessEnabled: deepseekHarnessResult.status === "fulfilled" && deepseekHarnessResult.value.enabled, + sandboxEndpointExportEnabled: + sandboxResult.status === "fulfilled" && + sandboxResult.value.endpointExportEnabled === true, skillCustomizationEnabled: skillResult.status === "fulfilled" && skillResult.value.enabled, }; @@ -301,6 +307,46 @@ function emptyInvocation(): FrontendInvocation { return { skills: [] }; } +async function loadSandboxThreadHistory( + session: SandboxSessionInfo, +): Promise { + let readError: unknown; + if (session.threadId) { + try { + const snapshot = await sandboxClient.readThread(session.id, session.threadId); + if (snapshot.messages.length > 0) return snapshot; + } catch (cause) { + readError = cause; + } + } + + const page = await sandboxClient.listThreads(session.id); + const latest = page.threads.find((thread) => thread.id !== session.threadId) ?? + page.threads[0]; + if (!latest) { + if (readError) throw readError; + return null; + } + return sandboxClient.resumeThread(session.id, latest.id); +} + +function sandboxSnapshotTurnsForStatus( + snapshot: SandboxThreadSnapshot, + busy: boolean, +): Turn[] { + const snapshotTurns = sandboxSnapshotTurns(snapshot); + const lastTurn = snapshotTurns[snapshotTurns.length - 1]; + if (!busy || lastTurn?.role !== "user") return snapshotTurns; + return [ + ...snapshotTurns, + { + role: "assistant", + blocks: [], + meta: { localId: `sandbox-background-${snapshot.threadId}` }, + }, + ]; +} + function activeWorkspaceDraftKey(userId: string): string { return `${workspaceDraftsKey(userId)}.active`; } @@ -884,6 +930,8 @@ export default function App() { const [sandboxApprovalBusy, setSandboxApprovalBusy] = useState(false); const [sandboxApprovalError, setSandboxApprovalError] = useState(""); const [sandboxUploadBusy, setSandboxUploadBusy] = useState(false); + const [sandboxEndpointCopyState, setSandboxEndpointCopyState] = + useState<"idle" | "copying" | "copied">("idle"); const [sandboxLaunchOpen, setSandboxLaunchOpen] = useState(false); const [sandboxLaunchState, setSandboxLaunchState] = useState("confirm"); @@ -891,6 +939,7 @@ export default function App() { const [sandboxLaunchKind, setSandboxLaunchKind] = useState<"codex" | SandboxAgentKind>("codex"); const [sandboxLaunchFromAgents, setSandboxLaunchFromAgents] = useState(false); + const [sandboxProjectUploadOpen, setSandboxProjectUploadOpen] = useState(false); const [sandboxAgentRefreshKey, setSandboxAgentRefreshKey] = useState(0); const [sandboxAgentDetailTarget, setSandboxAgentDetailTarget] = useState(null); @@ -903,9 +952,13 @@ export default function App() { const sandboxSessionIdRef = useRef(sandboxSession?.id ?? ""); const sandboxActiveAssistantTurnIdRef = useRef(""); const sandboxUploadRunRef = useRef(0); + const sandboxEndpointCopyTimerRef = useRef(undefined); const sandboxPreviewUrlsRef = useRef>(new Set()); sandboxSessionIdRef.current = sandboxSession?.id ?? ""; useEffect(() => () => { + if (sandboxEndpointCopyTimerRef.current !== undefined) { + window.clearTimeout(sandboxEndpointCopyTimerRef.current); + } for (const previewUrl of sandboxPreviewUrlsRef.current) { URL.revokeObjectURL(previewUrl); } @@ -930,6 +983,18 @@ export default function App() { sandboxPreviewUrlsRef.current.clear(); } + const resetSandboxEndpointCopyState = useCallback(() => { + if (sandboxEndpointCopyTimerRef.current !== undefined) { + window.clearTimeout(sandboxEndpointCopyTimerRef.current); + sandboxEndpointCopyTimerRef.current = undefined; + } + setSandboxEndpointCopyState("idle"); + }, []); + + useEffect(() => { + resetSandboxEndpointCopyState(); + }, [resetSandboxEndpointCopyState, sandboxSession?.id]); + // Turns are stored PER SESSION, so a background stream can keep updating its // own session's transcript while you view another one — no cross-session // leak, no data loss, and no re-fetch when you switch back (its entry is @@ -1430,6 +1495,72 @@ export default function App() { }, onError: setError, }); + useEffect(() => { + const activeSession = sandboxSession; + if (!activeSession || !sandboxBusy || sandboxMessageAbortRef.current) return; + let stopped = false; + let timer: number | undefined; + const controller = new AbortController(); + + const syncBackgroundTurn = async () => { + try { + const status = await sandboxClient.getStatus(activeSession.id, { + signal: controller.signal, + }); + if (stopped || sandboxSessionIdRef.current !== activeSession.id) return; + const snapshot = status.threadId + ? await sandboxClient.readThread(activeSession.id, status.threadId, { + signal: controller.signal, + }) + : null; + if (stopped || sandboxSessionIdRef.current !== activeSession.id) return; + if (snapshot) { + setSandboxTurns(sandboxSnapshotTurnsForStatus(snapshot, status.busy)); + } + setSandboxSession((current) => + current?.id === activeSession.id + ? { + ...current, + ...status, + ...(snapshot + ? { + threadId: snapshot.threadId, + cwd: snapshot.cwd ?? status.cwd, + model: snapshot.model ?? status.model, + workspaceLocked: snapshot.workspaceLocked, + permissions: snapshot.permissions, + } + : {}), + } + : current + ); + setSandboxBusy(status.busy); + if (!status.busy) { + const lastMessage = snapshot?.messages[snapshot.messages.length - 1]; + if (lastMessage?.role === "user") { + setError("云端 Codex 已结束,但没有生成回复,请重新发送任务。"); + } + return; + } + } catch (cause) { + if ((cause as Error)?.name === "AbortError" || stopped) return; + setSandboxBusy(false); + setSandboxSession((current) => + current?.id === activeSession.id ? { ...current, busy: false } : current + ); + setError(cause instanceof Error ? cause.message : String(cause)); + return; + } + timer = window.setTimeout(syncBackgroundTurn, 1500); + }; + + timer = window.setTimeout(syncBackgroundTurn, 1500); + return () => { + stopped = true; + controller.abort(); + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [sandboxBusy, sandboxSession?.id]); const activeAgent = activeAgentBySession[sessionId] ?? ""; const seenAgents = seenAgentsBySession[sessionId] ?? EMPTY_STRING_SET; const execPath = execPathBySession[sessionId] ?? EMPTY_STRING_ARR; @@ -3004,6 +3135,7 @@ export default function App() { } if (session.toolName === "codex") { const connected = await sandboxClient.connectSession(session.id); + const snapshot = await loadSandboxThreadHistory(connected); operation.succeed({ sandboxStatus: telemetrySandboxStatus(connected.status), }); @@ -3013,8 +3145,21 @@ export default function App() { setInput(""); setInvocation(emptyInvocation()); releaseAllSandboxPreviews(); - setSandboxTurns([]); - setSandboxSession(connected); + if (snapshot) { + setSandboxTurns(sandboxSnapshotTurnsForStatus(snapshot, connected.busy)); + setSandboxSession({ + ...connected, + threadId: snapshot.threadId, + cwd: snapshot.cwd ?? connected.cwd, + workspaceLocked: snapshot.workspaceLocked, + permissions: snapshot.permissions, + ...(snapshot.model ? { model: snapshot.model } : {}), + }); + } else { + setSandboxTurns([]); + setSandboxSession(connected); + } + setSandboxBusy(connected.busy); setSandboxAgentDetailTarget(null); setSandboxAgentWorkspace(null); setMyAgents(false); @@ -3039,6 +3184,21 @@ export default function App() { } } + async function openCodexHandoffSession(sessionId: string) { + const resources = await sandboxClient.listSessions(); + const session = resources.find( + (resource) => + resource.resourceType === "session" && + resource.toolName === "codex" && + resource.id === sessionId, + ); + if (!session) { + throw new Error("云端 Codex Session 暂未出现在列表中,请稍后重试。"); + } + await openSandboxAgent(session, "my_agents"); + setSandboxProjectUploadOpen(false); + } + function openSandboxAgentDetails(session: SandboxAgentResource) { setSandboxAgentDetailTarget(session); setSandboxAgentWorkspace(null); @@ -3094,6 +3254,7 @@ export default function App() { setSandboxApproval(null); setSandboxApprovalBusy(false); setSandboxApprovalError(""); + resetSandboxEndpointCopyState(); setSandboxThreadDeleteTarget(null); setSandboxUploadBusy(false); sandboxUploadRunRef.current += 1; @@ -3127,6 +3288,33 @@ export default function App() { } } + async function copySandboxEndpoint() { + const activeSession = sandboxSession; + if (!activeSession || sandboxEndpointCopyState === "copying") return; + setSandboxEndpointCopyState("copying"); + setError(""); + try { + if (!navigator.clipboard?.writeText) { + throw new Error("当前浏览器不支持写入剪贴板。"); + } + const exported = await sandboxClient.getEndpoint(activeSession.id); + await navigator.clipboard.writeText(exported.endpoint); + if (sandboxSessionIdRef.current !== activeSession.id) return; + setSandboxEndpointCopyState("copied"); + if (sandboxEndpointCopyTimerRef.current !== undefined) { + window.clearTimeout(sandboxEndpointCopyTimerRef.current); + } + sandboxEndpointCopyTimerRef.current = window.setTimeout(() => { + setSandboxEndpointCopyState("idle"); + sandboxEndpointCopyTimerRef.current = undefined; + }, 1600); + } catch (cause) { + if (sandboxSessionIdRef.current !== activeSession.id) return; + setSandboxEndpointCopyState("idle"); + setError(cause instanceof Error ? cause.message : String(cause)); + } + } + async function saveSandboxPermissions(value: SandboxPermissions) { const activeSession = sandboxSession; if (!activeSession || sandboxSettingsBusy) return; @@ -3354,7 +3542,13 @@ export default function App() { } function stopSandboxGeneration() { + const activeSessionId = sandboxSession?.id; sandboxMessageAbortRef.current?.abort(); + if (activeSessionId) { + void sandboxClient + .interruptSession(activeSessionId) + .catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))); + } } async function sendSandboxMessage( @@ -5081,6 +5275,10 @@ export default function App() { setSandboxSettingsError(""); setSandboxWorkspaceOpen(true); }, + onCopyEndpoint: copySandboxEndpoint, + endpointCopyEnabled: + newChatCapabilities.sandboxEndpointExportEnabled === true, + endpointCopyState: sandboxEndpointCopyState, workspaceLocked: sandboxSession.workspaceLocked, settingsBusy: sandboxSettingsBusy, uploadBusy: sandboxUploadBusy || sandboxBusy, @@ -5367,6 +5565,7 @@ export default function App() { canCreate={canCreateAgents} runtimeScope={access.capabilities.runtimeScope} onCreateAgent={openAgentCreateFromMyAgents} + onOpenCodexProjectUpload={() => setSandboxProjectUploadOpen(true)} onUseAgent={(agent) => connectMyAgent(agent, { source: "my_agents" }) } @@ -6076,6 +6275,13 @@ export default function App() { } /> + setSandboxProjectUploadOpen(false)} + onRefreshAgents={() => setSandboxAgentRefreshKey((current) => current + 1)} + onOpenSession={openCodexHandoffSession} + /> + {sandboxThreadDeleteTarget ? ( { @@ -25,6 +26,7 @@ async function getCapability(path: string): Promise { return { enabled: payload.enabled, reason: typeof payload.reason === "string" ? payload.reason : undefined, + endpointExportEnabled: payload.endpointExportEnabled === true, }; } diff --git a/frontend/src/adk/sandbox.ts b/frontend/src/adk/sandbox.ts index d29dc066..80edae83 100644 --- a/frontend/src/adk/sandbox.ts +++ b/frontend/src/adk/sandbox.ts @@ -4,6 +4,7 @@ import { requestSignal } from "./timeout"; import type { Block } from "../blocks"; const SANDBOX_API = "/web/sandbox/sessions"; +const CODEX_PROJECT_HANDOFF_API = "/web/sandbox/codex-project-handoff"; const LIST_TIMEOUT_MS = 30_000; const START_TIMEOUT_MS = 330_000; const CONNECT_TIMEOUT_MS = 60_000; @@ -11,6 +12,8 @@ const MESSAGE_TIMEOUT_MS = 600_000; const CLOSE_TIMEOUT_MS = 15_000; const SETTINGS_TIMEOUT_MS = 60_000; const UPLOAD_TIMEOUT_MS = 330_000; +const CODEX_PROJECT_HANDOFF_TIMEOUT_MS = 30_000; +export const CODEX_PROJECT_HANDOFF_PAIRING_TTL_SECONDS = 60 * 60; export const SANDBOX_DISPLAY_NAME_MAX_LENGTH = 40; export type SandboxAgentKind = "deepseek-harness" | "openclaw" | "hermes"; @@ -95,6 +98,41 @@ export interface SandboxToolLaunch { shellSessionId?: string; } +export interface SandboxEndpointExport { + endpoint: string; + sessionId: string; + expireAt?: string; +} + +export interface CodexProjectHandoffPairing { + pairingCode: string; + expireAt: string; + studioUrl: string; +} + +export type CodexProjectHandoffState = + | "issued" + | "creating" + | "session-created" + | "continuing" + | "running" + | "completed" + | "failed"; + +export interface CodexProjectHandoffStatus { + state: CodexProjectHandoffState; + expireAt: string; + projectName?: string; + agentName?: string; + sessionId?: string; + error?: string; + failedStage?: + | "creating-session" + | "uploading-project" + | "restoring-project" + | "continuing-task"; +} + export interface SandboxUploadedFile { id: string; path: string; @@ -142,12 +180,20 @@ export interface SandboxThreadSummary { status: string; } +export interface SandboxThreadImage { + mimeType: string; + data: string; + name?: string; + alt?: string; +} + export interface SandboxThreadMessage { id: string; role: "user" | "assistant"; content: string; timestamp: number; skillNames?: string[]; + images?: SandboxThreadImage[]; } export interface SandboxThreadSnapshot { @@ -286,6 +332,17 @@ export interface AgentKitSandboxClient { sessionId: string, options?: SandboxRequestOptions, ): Promise; + getEndpoint( + sessionId: string, + options?: SandboxRequestOptions, + ): Promise; + createCodexProjectHandoffPairing( + options?: SandboxRequestOptions, + ): Promise; + getCodexProjectHandoffStatus( + pairingCode: string, + options?: SandboxRequestOptions, + ): Promise; listModels( sessionId: string, options?: SandboxRequestOptions, @@ -305,6 +362,11 @@ export interface AgentKitSandboxClient { query?: { cursor?: string; search?: string; archived?: boolean }, options?: SandboxRequestOptions, ): Promise; + readThread( + sessionId: string, + threadId: string, + options?: SandboxRequestOptions, + ): Promise; newThread( sessionId: string, options?: SandboxRequestOptions, @@ -374,6 +436,10 @@ export interface AgentKitSandboxClient { sessionId: string, options?: SandboxRequestOptions, ): Promise; + interruptSession( + sessionId: string, + options?: SandboxRequestOptions, + ): Promise; deleteSession( sessionId: string, options?: SandboxRequestOptions, @@ -483,6 +549,18 @@ async function responseError(response: Response, fallback: string): Promise { + const text = await response.text().catch(() => ""); + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error(`${fallback} Studio 服务响应异常,请刷新后重试。`); + } +} + function parseSession( data: SessionResponse, toolName: SandboxSession["toolName"] = "codex", @@ -671,12 +749,35 @@ function parseThreadSnapshot(value: unknown): SandboxThreadSnapshot { (name): name is string => typeof name === "string" && Boolean(name), ) : []; + const images = Array.isArray(message.images) + ? message.images.flatMap((value): SandboxThreadImage[] => { + const image = recordOf(value); + if ( + !image || + typeof image.mimeType !== "string" || + !image.mimeType.startsWith("image/") || + typeof image.data !== "string" || + !image.data + ) return []; + return [{ + mimeType: image.mimeType, + data: image.data, + ...(typeof image.name === "string" && image.name + ? { name: image.name } + : {}), + ...(typeof image.alt === "string" && image.alt + ? { alt: image.alt } + : {}), + }]; + }) + : []; return [{ id: message.id, role: message.role, content: message.content, timestamp: message.timestamp, ...(skillNames.length ? { skillNames } : {}), + ...(images.length ? { images } : {}), }]; }); return { @@ -1151,6 +1252,127 @@ export const sandboxClient: AgentKitSandboxClient = { }; }, + async getEndpoint(sessionId, options = {}) { + const value = recordOf(await sandboxJson(sessionId, "endpoint", { + options, + fallback: "无法读取 Sandbox Endpoint。", + })); + if (typeof value?.endpoint !== "string" || !value.endpoint.trim()) { + throw new Error("Sandbox 返回了无效 Endpoint。"); + } + return { + endpoint: value.endpoint, + sessionId: + typeof value.sessionId === "string" ? value.sessionId : sessionId, + ...(typeof value.expireAt === "string" + ? { expireAt: value.expireAt } + : {}), + }; + }, + + async createCodexProjectHandoffPairing(options = {}) { + const response = await fetch( + withAuth(`${CODEX_PROJECT_HANDOFF_API}/pairings`), + { + method: "POST", + headers: sandboxHeaders({ + Accept: "application/json", + "Content-Type": "application/json", + }), + body: JSON.stringify({ + ttlSeconds: CODEX_PROJECT_HANDOFF_PAIRING_TTL_SECONDS, + }), + signal: requestSignal( + options.signal, + CODEX_PROJECT_HANDOFF_TIMEOUT_MS, + ), + }, + ); + if (!response.ok) { + throw await responseError( + response, + "无法生成 Codex 云端接力配对码。", + ); + } + const value = recordOf( + await responseJson(response, "无法生成 Codex 云端接力配对码。"), + ); + if ( + typeof value?.pairingCode !== "string" || + !value.pairingCode.trim() || + typeof value.expireAt !== "string" || + !value.expireAt.trim() + ) { + throw new Error("Studio 返回了无效的 Codex 云端接力配对码。"); + } + const studioUrl = typeof value.studioUrl === "string" && value.studioUrl.trim() + ? value.studioUrl.trim() + : window.location.origin; + return { + pairingCode: value.pairingCode, + expireAt: value.expireAt, + studioUrl, + }; + }, + + async getCodexProjectHandoffStatus(pairingCode, options = {}) { + const response = await fetch( + withAuth( + `${CODEX_PROJECT_HANDOFF_API}/pairings/${encodeURIComponent(pairingCode)}`, + ), + { + headers: sandboxHeaders({ Accept: "application/json" }), + signal: requestSignal( + options.signal, + CODEX_PROJECT_HANDOFF_TIMEOUT_MS, + ), + }, + ); + if (!response.ok) { + throw await responseError(response, "无法读取端云接力状态。"); + } + const value = recordOf( + await responseJson(response, "无法读取端云接力状态。"), + ); + const states: ReadonlySet = new Set([ + "issued", + "creating", + "session-created", + "continuing", + "running", + "completed", + "failed", + ]); + if ( + typeof value?.state !== "string" || + !states.has(value.state) || + typeof value.expireAt !== "string" || + !value.expireAt.trim() + ) { + throw new Error("Studio 返回了无效的端云接力状态。"); + } + return { + state: value.state as CodexProjectHandoffState, + expireAt: value.expireAt, + ...(typeof value.projectName === "string" + ? { projectName: value.projectName } + : {}), + ...(typeof value.agentName === "string" + ? { agentName: value.agentName } + : {}), + ...(typeof value.sessionId === "string" + ? { sessionId: value.sessionId } + : {}), + ...(typeof value.error === "string" ? { error: value.error } : {}), + ...(value.failedStage === "creating-session" || + value.failedStage === "uploading-project" || + value.failedStage === "restoring-project" || + value.failedStage === "continuing-task" + ? { failedStage: value.failedStage } + : {}), + }; + }, + async listModels(sessionId, options = {}) { const value = recordOf(await sandboxJson(sessionId, "models", { options, @@ -1225,6 +1447,20 @@ export const sandboxClient: AgentKitSandboxClient = { })); }, + async readThread(sessionId, threadId, options = {}) { + if (!threadId) throw new Error("缺少要读取的 Codex Thread。"); + return parseThreadSnapshot( + await sandboxJson( + sessionId, + `threads/${encodeURIComponent(threadId)}`, + { + options, + fallback: "无法读取 Codex 历史消息。", + }, + ), + ); + }, + async resumeThread(sessionId, threadId, options = {}) { return parseThreadSnapshot( await sandboxJson(sessionId, "threads/resume", { @@ -1450,6 +1686,21 @@ export const sandboxClient: AgentKitSandboxClient = { } }, + async interruptSession(sessionId, options = {}) { + if (!sessionId) return; + const response = await fetch( + withAuth(`${SANDBOX_API}/${encodeURIComponent(sessionId)}/interrupt`), + { + method: "POST", + headers: sandboxHeaders(), + signal: requestSignal(options.signal, CLOSE_TIMEOUT_MS), + }, + ); + if (!response.ok && response.status !== 404) { + throw await responseError(response, "无法停止 Codex 任务。"); + } + }, + async deleteSession(sessionId, options = {}) { if (!sessionId) return; const response = await fetch( diff --git a/frontend/src/ui/KnowledgeLibrary.css b/frontend/src/ui/KnowledgeLibrary.css index 53b48d1b..cfeb566d 100644 --- a/frontend/src/ui/KnowledgeLibrary.css +++ b/frontend/src/ui/KnowledgeLibrary.css @@ -1379,9 +1379,9 @@ display: grid; place-items: center; padding: 0; - border: 1px solid hsl(var(--border)); + border: 1px solid transparent; border-radius: 8px; - background: hsl(var(--panel)); + background: transparent; color: hsl(var(--muted-foreground)); cursor: pointer; } diff --git a/frontend/src/ui/MyAgents.css b/frontend/src/ui/MyAgents.css index 81b6b0d4..c0fa09fa 100644 --- a/frontend/src/ui/MyAgents.css +++ b/frontend/src/ui/MyAgents.css @@ -102,6 +102,13 @@ gap: 8px; } +.my-agent-type-actions { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 8px; +} + .my-agent-type-pill { min-height: 30px; padding: 0 13px; @@ -127,6 +134,107 @@ color: hsl(var(--background)); } +.my-agent-create-secondary { + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 13px; + border: 1px solid var(--purple-500); + border-radius: 8px; + background-color: var(--purple-500); + background-image: linear-gradient( + 105deg, + var(--blue-500) 0%, + var(--purple-500) 36%, + color-mix(in srgb, var(--purple-400) 94%, var(--purple-500)) 60%, + var(--blue-500) 100% + ); + background-position: 32% 50%; + background-size: 160% 100%; + color: var(--white); + cursor: pointer; + font: inherit; + font-size: 12.5px; + font-weight: 500; + line-height: 1; + transition: + border-color 160ms ease, + background-color 160ms ease, + background-position 180ms ease, + color 160ms ease; +} + +.my-agent-create-secondary:hover:not(:disabled) { + border-color: var(--purple-400); + background-position: 68% 50%; +} + +.my-agent-create-secondary:active:not(:disabled) { + border-color: var(--purple-500); + background-color: var(--purple-500); + background-image: linear-gradient( + 105deg, + var(--blue-500) 0%, + var(--purple-500) 46%, + var(--blue-500) 100% + ); + background-position: 32% 50%; + background-size: 160% 100%; +} + +.my-agent-create-secondary:disabled { + border-color: hsl(var(--border)); + background: hsl(var(--secondary)); + background-image: none; + color: hsl(var(--muted-foreground)); + cursor: not-allowed; + opacity: 0.58; +} + +.my-agent-handoff-button { + color: var(--white); +} + +.my-agent-handoff-button::before { + background-color: var(--purple-500); + background-image: linear-gradient( + 105deg, + var(--blue-500) 0%, + var(--purple-500) 36%, + color-mix(in srgb, var(--purple-400) 94%, var(--purple-500)) 60%, + var(--blue-500) 100% + ); + background-position: 32% 50%; + background-size: 160% 100%; + transition-property: + opacity, + background-color, + background-position, + transform, + box-shadow, + border-color; +} + +.my-agent-handoff-button:hover:not(:disabled)::before { + background-position: 68% 50%; +} + +.my-agent-handoff-button:active:not(:disabled)::before { + background-color: var(--purple-500); + background-image: linear-gradient( + 105deg, + var(--blue-500) 0%, + var(--purple-500) 46%, + var(--blue-500) 100% + ); +} + +.my-agent-handoff-button:disabled::before { + background-image: none; +} + .my-agent-results { flex: 1; min-height: 0; @@ -198,6 +306,7 @@ font: inherit; font-size: 12.5px; font-weight: 500; + line-height: 1; transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease; } @@ -214,6 +323,7 @@ opacity: 0.58; } +.my-agent-create-secondary svg, .my-agent-create-primary svg { width: 14px; height: 14px; @@ -602,12 +712,17 @@ .my-agent-actions button:focus-visible, .my-agent-type-pill:focus-visible, +.my-agent-create-secondary:focus-visible, .my-agent-create-primary:focus-visible, .my-agent-empty button:focus-visible { outline: 2px solid hsl(var(--ring) / 0.65); outline-offset: -2px; } +.my-agent-create-secondary:focus-visible { + outline-color: var(--color-ring-discovery-solid); +} + @keyframes my-agent-card-enter { from { opacity: 0; @@ -639,7 +754,7 @@ gap: 12px; } - .my-agent-create-primary { + .my-agent-type-actions { align-self: flex-end; } @@ -663,6 +778,7 @@ .my-agent-card, .my-agent-actions button, .my-agent-type-pill, + .my-agent-create-secondary, .my-agent-create-primary, .my-agent-search { transition: none; diff --git a/frontend/src/ui/MyAgents.tsx b/frontend/src/ui/MyAgents.tsx index 72ea5c46..7024d07b 100644 --- a/frontend/src/ui/MyAgents.tsx +++ b/frontend/src/ui/MyAgents.tsx @@ -106,6 +106,20 @@ function AddIcon(props: SVGProps) { ); } +function HandoffIcon(props: SVGProps) { + return ( + + ); +} + function AgentTypeIcon({ type }: { type: AgentType }) { if (type === "general") return ; return ; @@ -392,6 +406,7 @@ export interface MyAgentsProps { canCreate: boolean; runtimeScope: RuntimeScope; onCreateAgent: (region: string) => void; + onOpenCodexProjectUpload?: () => void; onUseAgent: (agent: MyAgentCardData) => Promise; onViewAgentDetails: (agent: MyAgentCardData) => void; onCreateSandboxAgent: (kind: "codex" | SandboxAgentKind) => void; @@ -413,6 +428,7 @@ export function MyAgents({ canCreate, runtimeScope, onCreateAgent, + onOpenCodexProjectUpload, onUseAgent, onViewAgentDetails, onCreateSandboxAgent, @@ -655,6 +671,8 @@ export function MyAgents({ ? () => onCreateAgent(defaultCloudRegion(cloudProvider)) : () => onCreateSandboxAgent(activeType) : undefined; + const showCodexProjectUpload = + activeType === "codex" && canCreate && Boolean(onOpenCodexProjectUpload); const createDisabledReason = !canCreate ? "当前账号没有创建智能体权限" : undefined; @@ -698,16 +716,28 @@ export function MyAgents({ ))} - +
+ {showCodexProjectUpload ? ( + + ) : null} + +
{canCreate ? ( + {activeType === "codex" && onOpenCodexProjectUpload ? ( + + ) : null} + {actions.endpointCopyEnabled && actions.onCopyEndpoint ? ( + + ) : null}
diff --git a/frontend/src/ui/SandboxProjectUploadDialog.css b/frontend/src/ui/SandboxProjectUploadDialog.css new file mode 100644 index 00000000..0b270e37 --- /dev/null +++ b/frontend/src/ui/SandboxProjectUploadDialog.css @@ -0,0 +1,643 @@ +.sandbox-project-upload-backdrop { + position: fixed; + inset: 0; + z-index: 110; + display: grid; + place-items: center; + padding: 20px; + background: hsl(var(--foreground) / 0.24); + backdrop-filter: blur(3px); +} + +.sandbox-project-upload-dialog { + width: min(680px, calc(100vw - 40px)); + max-height: min(760px, calc(100vh - 40px)); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--panel)); + color: hsl(var(--foreground)); + box-shadow: 0 20px 56px hsl(var(--foreground) / 0.2); + animation: sandbox-project-upload-enter 180ms ease-out both; +} + +.sandbox-project-upload-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 20px 22px 16px; + border-bottom: 1px solid hsl(var(--border)); +} + +.sandbox-project-upload-head h2, +.sandbox-project-upload-head p { + margin: 0; +} + +.sandbox-project-upload-title-row { + display: flex; + align-items: center; + gap: 8px; +} + +.sandbox-project-upload-title-row .sandbox-project-upload-beta { + height: 18px; + padding-inline: 6px; + font-size: 10px; + font-weight: 600; + line-height: 1.2; +} + +.sandbox-project-upload-head h2 { + font-size: 17px; + font-weight: 650; + line-height: 1.3; +} + +.sandbox-project-upload-head p { + margin-top: 6px; + color: hsl(var(--muted-foreground)); + font-size: 13px; + line-height: 1.55; +} + +.sandbox-project-upload-close { + width: 32px; + height: 32px; + display: inline-grid; + flex: 0 0 32px; + place-items: center; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; +} + +.sandbox-project-upload-close:hover { + border-color: hsl(var(--border)); + background: hsl(var(--secondary)); + color: hsl(var(--foreground)); +} + +.sandbox-project-upload-close svg { + width: 17px; + height: 17px; +} + +.sandbox-project-upload-body { + min-height: 0; + overflow-y: auto; + display: grid; + gap: 14px; + padding: 16px 22px 20px; +} + +.sandbox-project-upload-pairing-notice { + min-height: 38px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 8px 6px 12px; + border: 1px solid hsl(43 90% 48% / 0.28); + border-radius: 8px; + background: hsl(45 96% 52% / 0.12); + color: hsl(38 78% 30%); + font-size: 12px; + line-height: 1.45; + white-space: nowrap; +} + +.sandbox-project-upload-pairing-notice time { + font-variant-numeric: tabular-nums; + font-weight: 650; +} + +.sandbox-project-upload-pairing-notice button { + height: 26px; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 5px; + padding: 0 7px; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + font-weight: 600; +} + +.sandbox-project-upload-pairing-notice button:hover:not(:disabled) { + background: hsl(43 90% 48% / 0.12); +} + +.sandbox-project-upload-pairing-notice button:disabled { + cursor: default; + opacity: 0.6; +} + +.sandbox-project-upload-pairing-notice svg { + width: 14px; + height: 14px; +} + +.sandbox-project-upload-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid hsl(var(--destructive) / 0.22); + border-radius: 8px; + background: hsl(var(--destructive) / 0.05); + color: hsl(var(--destructive)); + font-size: 12px; +} + +.sandbox-project-upload-error span { + min-width: 0; + overflow-wrap: anywhere; +} + +.sandbox-project-upload-stage { + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); +} + +.sandbox-project-upload-stage-head { + min-height: 64px; + box-sizing: border-box; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid hsl(var(--border)); +} + +.sandbox-project-upload-stage-number { + width: 22px; + height: 22px; + display: inline-grid; + place-items: center; + border-radius: 50%; + background: hsl(var(--foreground)); + color: hsl(var(--background)); + font-size: 11.5px; + font-weight: 650; + line-height: 1; +} + +.sandbox-project-upload-stage-head > div { + min-width: 0; +} + +.sandbox-project-upload-stage-head h3 { + margin: 0; + color: hsl(var(--foreground)); + font-size: 13px; + font-weight: 650; + line-height: 1.4; +} + +.sandbox-project-upload-stage-head p { + margin: 3px 0 0; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.45; +} + +.sandbox-project-upload-stage-head button, +.sandbox-project-upload-error button, +.sandbox-project-upload-actions button { + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--panel)); + color: hsl(var(--foreground)); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.sandbox-project-upload-stage-head button, +.sandbox-project-upload-error button { + padding: 0 10px; +} + +.sandbox-project-upload-stage-head button:hover:not(:disabled), +.sandbox-project-upload-error button:hover, +.sandbox-project-upload-actions button:hover { + background: hsl(var(--secondary)); +} + +.sandbox-project-upload-stage-head button:disabled { + cursor: default; + opacity: 0.56; +} + +.sandbox-project-upload-stage-head svg, +.sandbox-project-upload-error svg, +.sandbox-project-upload-actions svg { + width: 14px; + height: 14px; + flex: 0 0 14px; +} + +.sandbox-project-upload-install-tabs { + position: relative; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + height: 36px; + margin: 10px 10px 0; + padding: 3px; + box-sizing: border-box; + border: 1px solid hsl(var(--border) / 0.58); + border-radius: 8px; + background: hsl(var(--secondary) / 0.58); +} + +.sandbox-project-upload-install-tabs > span { + position: absolute; + z-index: 0; + top: 3px; + bottom: 3px; + left: 3px; + width: calc(50% - 3px); + border: 1px solid hsl(var(--border) / 0.72); + border-radius: 6px; + background: hsl(var(--background)); + transform: translateX(0); + transition: transform 180ms ease-out; +} + +.sandbox-project-upload-install-tabs.is-terminal > span { + transform: translateX(100%); +} + +.sandbox-project-upload-install-tabs button { + position: relative; + z-index: 1; + min-width: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 550; + text-align: center; + transition: color 160ms ease-out; +} + +.sandbox-project-upload-install-tabs button:hover, +.sandbox-project-upload-install-tabs button[aria-selected="true"] { + color: hsl(var(--foreground)); +} + +.sandbox-project-upload-prompt { + min-height: 92px; + display: flex; +} + +.sandbox-project-upload-prompt pre { + width: 100%; + box-sizing: border-box; + margin: 0; + padding: 12px; + color: hsl(var(--foreground)); + font-family: inherit; + font-size: 13px; + font-weight: 400; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.sandbox-project-upload-prompt code { + font-family: inherit; + font-size: 13px; + font-weight: 400; +} + +.sandbox-project-upload-prompt.is-command code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", monospace; + font-size: 12px; +} + +.sandbox-project-upload-loading { + width: 100%; + min-height: 92px; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + color: hsl(var(--muted-foreground)); + font-size: 12px; +} + +.sandbox-project-upload-loading i { + width: 14px; + height: 14px; + flex: 0 0 14px; + border: 2px solid hsl(var(--foreground) / 0.16); + border-top-color: hsl(var(--foreground)); + border-radius: 50%; + animation: sandbox-project-upload-spin 800ms linear infinite; +} + +.sandbox-project-upload-stage > .sandbox-project-upload-pairing-notice { + margin: 10px 10px 0; +} + +.sandbox-project-upload-stage > .sandbox-project-upload-pairing-notice + + .sandbox-project-upload-prompt { + min-height: 104px; +} + +.sandbox-project-upload-progress { + border-top: 1px solid hsl(var(--border)); +} + +.sandbox-project-upload-progress > header { + min-height: 58px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 9px 12px; + border-bottom: 1px solid hsl(var(--border)); +} + +.sandbox-project-upload-progress > header div { + min-width: 0; +} + +.sandbox-project-upload-progress > header span { + display: block; + font-size: 12.5px; + font-weight: 650; + line-height: 1.4; +} + +.sandbox-project-upload-progress > header p { + margin: 3px 0 0; + overflow: hidden; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sandbox-project-upload-progress > header strong { + flex: 0 0 auto; + padding: 4px 8px; + border-radius: 6px; + background: hsl(var(--secondary)); + color: hsl(var(--muted-foreground)); + font-size: 11.5px; + font-weight: 600; + line-height: 1.35; +} + +.sandbox-project-upload-progress > header strong[data-state="completed"] { + background: hsl(142 42% 94%); + color: hsl(142 55% 28%); +} + +.sandbox-project-upload-progress > header strong[data-state="running"] { + background: hsl(210 72% 94%); + color: hsl(213 62% 34%); +} + +.sandbox-project-upload-progress > header strong[data-state="failed"] { + background: hsl(var(--destructive) / 0.08); + color: hsl(var(--destructive)); +} + +.sandbox-project-upload-progress ol { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + margin: 0; + padding: 12px; + list-style: none; +} + +.sandbox-project-upload-progress li { + position: relative; + min-width: 0; + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + column-gap: 7px; + padding-right: 28px; + color: hsl(var(--muted-foreground)); + font-size: 11.5px; + line-height: 1.4; +} + +.sandbox-project-upload-progress li > span:last-child { + min-width: 0; + white-space: nowrap; +} + +.sandbox-project-upload-progress li:not(:last-child)::after { + position: absolute; + top: 50%; + right: 6px; + width: 16px; + height: 1px; + background: hsl(var(--border)); + content: ""; +} + +.sandbox-project-upload-progress li:last-child { + padding-right: 0; +} + +.sandbox-project-upload-progress li[data-state="active"] { + color: hsl(var(--foreground)); + font-weight: 600; +} + +.sandbox-project-upload-progress li[data-state="done"] { + color: hsl(var(--foreground)); +} + +.sandbox-project-upload-progress li[data-state="failed"] { + color: hsl(var(--destructive)); + font-weight: 600; +} + +.sandbox-project-upload-progress-marker { + width: 18px; + height: 18px; + display: inline-grid; + flex: 0 0 18px; + place-items: center; + box-sizing: border-box; + border: 1px solid hsl(var(--border)); + border-radius: 50%; + background: hsl(var(--panel)); +} + +.sandbox-project-upload-progress li[data-state="active"] + .sandbox-project-upload-progress-marker { + border: 2px solid hsl(var(--foreground) / 0.2); + border-top-color: hsl(var(--foreground)); + animation: sandbox-project-upload-spin 800ms linear infinite; +} + +.sandbox-project-upload-progress li[data-state="done"] + .sandbox-project-upload-progress-marker { + border-color: hsl(142 42% 40% / 0.3); + background: hsl(142 42% 94%); + color: hsl(142 55% 28%); +} + +.sandbox-project-upload-progress li[data-state="failed"] + .sandbox-project-upload-progress-marker { + border-color: hsl(var(--destructive) / 0.25); + background: hsl(var(--destructive) / 0.06); +} + +.sandbox-project-upload-progress-marker svg { + width: 12px; + height: 12px; +} + +.sandbox-project-upload-progress-error { + margin: 0; + padding: 0 12px 12px; + color: hsl(var(--destructive)); + font-size: 12px; + line-height: 1.5; +} + +.sandbox-project-upload-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid hsl(var(--border)); +} + +.sandbox-project-upload-actions button { + min-width: 86px; + padding: 0 14px; +} + +.sandbox-project-upload-actions .is-primary { + border-color: hsl(var(--foreground)); + background: hsl(var(--foreground)); + color: hsl(var(--background)); +} + +.sandbox-project-upload-actions .is-primary:hover { + background: hsl(var(--foreground) / 0.9); +} + +.sandbox-project-upload-dialog button:focus-visible, +.sandbox-project-upload-install-tabs button:focus-visible, +.sandbox-project-upload-prompt pre:focus-visible { + outline: 2px solid hsl(var(--ring) / 0.34); + outline-offset: 2px; +} + +@keyframes sandbox-project-upload-enter { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes sandbox-project-upload-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 640px) { + .sandbox-project-upload-backdrop { + padding: 10px; + } + + .sandbox-project-upload-dialog { + width: calc(100vw - 20px); + max-height: calc(100vh - 20px); + } + + .sandbox-project-upload-head, + .sandbox-project-upload-body { + padding-left: 16px; + padding-right: 16px; + } + + .sandbox-project-upload-pairing-notice { + gap: 6px; + padding-left: 10px; + font-size: 11.5px; + } + + .sandbox-project-upload-stage-head { + grid-template-columns: 24px minmax(0, 1fr); + } + + .sandbox-project-upload-stage-head button { + grid-column: 2; + justify-self: start; + } + + .sandbox-project-upload-actions button { + flex: 1; + } + + .sandbox-project-upload-progress ol { + grid-template-columns: 1fr 1fr; + gap: 10px 12px; + } + + .sandbox-project-upload-progress li::after { + display: none; + } + + .sandbox-project-upload-progress li { + padding-right: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .sandbox-project-upload-dialog, + .sandbox-project-upload-install-tabs > span, + .sandbox-project-upload-install-tabs button, + .sandbox-project-upload-progress li[data-state="active"] + .sandbox-project-upload-progress-marker, + .sandbox-project-upload-loading i { + animation: none; + transition: none; + } +} diff --git a/frontend/src/ui/SandboxProjectUploadDialog.tsx b/frontend/src/ui/SandboxProjectUploadDialog.tsx new file mode 100644 index 00000000..f2c339d9 --- /dev/null +++ b/frontend/src/ui/SandboxProjectUploadDialog.tsx @@ -0,0 +1,698 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type SVGProps, +} from "react"; +import { createPortal } from "react-dom"; +import { Badge } from "@openai/apps-sdk-ui/components/Badge"; +import { + sandboxClient, + type CodexProjectHandoffPairing, + type CodexProjectHandoffStatus, +} from "../adk/sandbox"; +import "./SandboxProjectUploadDialog.css"; + +type InstallMethod = "conversation" | "terminal"; +type CopyTarget = "install-conversation" | "install-terminal" | "handoff" | ""; + +interface DialogError { + message: string; + retryPairing: boolean; +} + +export interface SandboxProjectUploadDialogProps { + open: boolean; + onClose: () => void; + onRefreshAgents: () => void; + onOpenSession: (sessionId: string) => Promise; +} + +function trimStudioUrl(value: string): string { + return value.trim().replace(/\/+$/, "") || window.location.origin; +} + +function codexHandoffPrompt(pairing: CodexProjectHandoffPairing): string { + const studioUrl = trimStudioUrl(pairing.studioUrl); + return [ + "使用 AgentKit Studio Plugin 端云接力当前会话、项目和任务。请直接执行,不要让我手动打开终端。", + `Studio:${studioUrl}`, + `配对码:${pairing.pairingCode}`, + ].join("\n"); +} + +function installPluginCommand(): string { + return [ + "codex plugin marketplace add volcengine/veadk-python", + "--sparse .agents/plugins", + "--sparse plugins/agentkit-studio", + "&& codex plugin add agentkit-studio@veadk-python", + ].join(" "); +} + +function installPluginPrompt(): string { + return [ + "请安装 AgentKit Studio Plugin。请直接执行以下安装命令,不要让我手动打开终端。", + `安装命令:${installPluginCommand()}`, + ].join("\n"); +} + +function CloseIcon(props: SVGProps) { + return ( + + ); +} + +function CopyIcon(props: SVGProps) { + return ( + + ); +} + +function CheckIcon(props: SVGProps) { + return ( + + ); +} + +function RefreshIcon(props: SVGProps) { + return ( + + ); +} + +export function formatPairingCountdown(expireAt: string, nowMs: number): string { + const remainingSeconds = Math.max( + 0, + Math.ceil((Date.parse(expireAt) - nowMs) / 1000), + ); + const hours = Math.floor(remainingSeconds / 3600); + const minutes = Math.floor((remainingSeconds % 3600) / 60); + const seconds = remainingSeconds % 60; + return [hours, minutes, seconds] + .map((value) => String(value).padStart(2, "0")) + .join(":"); +} + +type ProgressState = "pending" | "active" | "done" | "failed"; + +interface HandoffProgressStep { + id: "request" | "session" | "restore" | "continue"; + label: string; +} + +const HANDOFF_PROGRESS_STEPS: readonly HandoffProgressStep[] = [ + { id: "request", label: "等待端侧请求" }, + { id: "session", label: "创建云端 Session" }, + { id: "restore", label: "恢复项目" }, + { id: "continue", label: "发送续跑任务" }, +]; + +function progressRank(status: CodexProjectHandoffStatus): number { + switch (status.state) { + case "issued": + return 0; + case "creating": + return 1; + case "session-created": + return 2; + case "continuing": + return 3; + case "running": + return 4; + case "completed": + return 4; + case "failed": + if (status.failedStage === "creating-session") return 1; + if ( + status.failedStage === "uploading-project" || + status.failedStage === "restoring-project" + ) { + return 2; + } + return 3; + } +} + +function progressStepState( + status: CodexProjectHandoffStatus, + index: number, +): ProgressState { + const rank = progressRank(status); + if (status.state === "failed" && index === rank) return "failed"; + if (index < rank) return "done"; + if (index === rank && status.state !== "completed") return "active"; + return status.state === "completed" ? "done" : "pending"; +} + +function handoffStatusLabel(status: CodexProjectHandoffStatus): string { + switch (status.state) { + case "issued": + return "等待请求"; + case "creating": + return "正在创建 Session"; + case "session-created": + return "正在迁移项目"; + case "continuing": + return "正在启动云端任务"; + case "running": + return "云端执行中"; + case "completed": + return "接力完成"; + case "failed": + return "接力失败"; + } +} + +export function SandboxProjectUploadDialog({ + open, + onClose, + onRefreshAgents, + onOpenSession, +}: SandboxProjectUploadDialogProps) { + const dialogRef = useRef(null); + const closeRef = useRef(null); + const onCloseRef = useRef(onClose); + const requestRef = useRef(0); + const copyTimerRef = useRef(undefined); + const completionNotifiedRef = useRef(""); + const [installMethod, setInstallMethod] = + useState("conversation"); + const [pairing, setPairing] = useState(null); + const [handoffStatus, setHandoffStatus] = + useState(null); + const [pairingRetryKey, setPairingRetryKey] = useState(0); + const [loading, setLoading] = useState(false); + const [countdownNow, setCountdownNow] = useState(Date.now()); + const [enteringSession, setEnteringSession] = useState(false); + const [error, setError] = useState(null); + const [copyTarget, setCopyTarget] = useState(""); + onCloseRef.current = onClose; + + const installPrompt = useMemo(() => installPluginPrompt(), []); + const installCommand = useMemo(() => installPluginCommand(), []); + const handoffPrompt = useMemo( + () => pairing ? codexHandoffPrompt(pairing) : "", + [pairing], + ); + + useEffect(() => { + if (!open) return; + setPairing(null); + setHandoffStatus(null); + setInstallMethod("conversation"); + setError(null); + setCopyTarget(""); + setEnteringSession(false); + setCountdownNow(Date.now()); + const controller = new AbortController(); + const requestId = ++requestRef.current; + setLoading(true); + void sandboxClient + .createCodexProjectHandoffPairing({ signal: controller.signal }) + .then((value) => { + if (requestRef.current !== requestId) return; + setPairing(value); + setHandoffStatus({ state: "issued", expireAt: value.expireAt }); + }) + .catch((cause) => { + if ((cause as Error)?.name === "AbortError") return; + if (requestRef.current === requestId) { + setError({ + message: cause instanceof Error ? cause.message : String(cause), + retryPairing: true, + }); + } + }) + .finally(() => { + if (requestRef.current === requestId) setLoading(false); + }); + return () => { + controller.abort(); + }; + }, [pairingRetryKey, open]); + + useEffect(() => { + if (!open || !pairing) return; + setCountdownNow(Date.now()); + const timer = window.setInterval(() => setCountdownNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [open, pairing]); + + useEffect(() => { + if (!open || !pairing) return; + let stopped = false; + let timer: number | undefined; + const controller = new AbortController(); + + const poll = async () => { + if (stopped || Date.now() >= Date.parse(pairing.expireAt)) return; + try { + const value = await sandboxClient.getCodexProjectHandoffStatus( + pairing.pairingCode, + { signal: controller.signal }, + ); + if (stopped) return; + setHandoffStatus(value); + if (value.state === "completed" || value.state === "failed") return; + timer = window.setTimeout(() => void poll(), 1500); + return; + } catch (cause) { + if ((cause as Error)?.name === "AbortError" || stopped) return; + setError({ + message: cause instanceof Error ? cause.message : String(cause), + retryPairing: false, + }); + } + timer = window.setTimeout(() => void poll(), 1500); + }; + + void poll(); + return () => { + stopped = true; + controller.abort(); + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [open, pairing]); + + useEffect(() => { + if ( + (handoffStatus?.state !== "running" && + handoffStatus?.state !== "completed") || + !pairing || + completionNotifiedRef.current === pairing.pairingCode + ) return; + completionNotifiedRef.current = pairing.pairingCode; + onRefreshAgents(); + }, [handoffStatus?.state, onRefreshAgents, pairing]); + + useEffect(() => () => { + if (copyTimerRef.current !== undefined) { + window.clearTimeout(copyTimerRef.current); + } + }, []); + + useEffect(() => { + if (!open) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const focusFrame = window.requestAnimationFrame(() => closeRef.current?.focus()); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCloseRef.current(); + return; + } + if (event.key !== "Tab") return; + const controls = dialogRef.current?.querySelectorAll( + 'button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex="-1"])', + ); + if (!controls?.length) return; + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => { + window.cancelAnimationFrame(focusFrame); + document.body.style.overflow = previousOverflow; + window.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + if (!open) return null; + + async function copy(value: string, target: CopyTarget) { + if (!value || copyTarget) return; + setError(null); + setCopyTarget(target); + try { + if (!navigator.clipboard?.writeText) { + throw new Error("当前浏览器不支持写入剪贴板。"); + } + await navigator.clipboard.writeText(value); + if (copyTimerRef.current !== undefined) { + window.clearTimeout(copyTimerRef.current); + } + copyTimerRef.current = window.setTimeout(() => { + setCopyTarget((current) => current === target ? "" : current); + copyTimerRef.current = undefined; + }, 1400); + } catch (cause) { + setCopyTarget(""); + setError({ + message: cause instanceof Error ? cause.message : String(cause), + retryPairing: false, + }); + } + } + + async function enterCodexSession() { + const sessionId = handoffStatus?.sessionId; + if (!sessionId || enteringSession) return; + setError(null); + setEnteringSession(true); + try { + await onOpenSession(sessionId); + } catch (cause) { + setError({ + message: cause instanceof Error ? cause.message : String(cause), + retryPairing: false, + }); + setEnteringSession(false); + } + } + + function selectInstallMethod(method: InstallMethod) { + setInstallMethod(method); + document.getElementById(`sandbox-project-upload-install-${method}-tab`)?.focus(); + } + + function handleInstallTabKeyDown( + event: ReactKeyboardEvent, + ) { + const methods: readonly InstallMethod[] = ["conversation", "terminal"]; + const currentIndex = methods.indexOf(installMethod); + let nextIndex: number | null = null; + if (event.key === "ArrowRight") nextIndex = (currentIndex + 1) % methods.length; + if (event.key === "ArrowLeft") { + nextIndex = (currentIndex - 1 + methods.length) % methods.length; + } + if (event.key === "Home") nextIndex = 0; + if (event.key === "End") nextIndex = methods.length - 1; + if (nextIndex === null) return; + event.preventDefault(); + selectInstallMethod(methods[nextIndex]); + } + + const countdown = pairing + ? formatPairingCountdown(pairing.expireAt, countdownNow) + : "00:00:00"; + const pairingExpired = pairing + ? countdownNow >= Date.parse(pairing.expireAt) + : false; + + return createPortal( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+
+
+

接力到云端继续执行

+ + Beta + +
+

+ 按顺序复制两段提示词,Codex 会通过插件将您的本地任务接力到云端 +

+
+ +
+ +
+ {error ? ( +
+ {error.message} + {error.retryPairing ? ( + + ) : null} +
+ ) : null} + +
+
+ 1 +
+

安装插件

+

首次使用时,请选择一种安装方式。

+
+ +
+ +
+
+ +
+
+                {installMethod === "conversation" ? installPrompt : installCommand}
+              
+
+
+ +
+
+ 2 +
+

任务接力

+

插件安装完成后复制,Codex 会迁移当前项目并继续执行任务。

+
+ +
+ +
+ + {loading + ? "正在生成新的配对码" + : pairingExpired + ? "配对码已过期" + : <>配对码有效期剩余 } + + +
+ +
+ {loading ? ( +
+