From 834ea2ba2bcfd313d8a7c09f315fe2f5ef03ff47 Mon Sep 17 00:00:00 2001 From: xhluca Date: Wed, 26 Aug 2026 11:08:11 -0400 Subject: [PATCH 01/13] feat: add Grok Kilo and OpenHands adapters --- src/session_migrate/formats/__init__.py | 6 + src/session_migrate/formats/grok.py | 524 ++++++++++++++++ src/session_migrate/formats/kilo.py | 87 +++ src/session_migrate/formats/openhands.py | 741 +++++++++++++++++++++++ src/session_migrate/model.py | 6 + tests/test_grok_format.py | 210 +++++++ tests/test_kilo_format.py | 64 ++ tests/test_openhands_format.py | 271 +++++++++ 8 files changed, 1909 insertions(+) create mode 100644 src/session_migrate/formats/grok.py create mode 100644 src/session_migrate/formats/kilo.py create mode 100644 src/session_migrate/formats/openhands.py create mode 100644 tests/test_grok_format.py create mode 100644 tests/test_kilo_format.py create mode 100644 tests/test_openhands_format.py diff --git a/src/session_migrate/formats/__init__.py b/src/session_migrate/formats/__init__.py index ba46064..8efcab7 100644 --- a/src/session_migrate/formats/__init__.py +++ b/src/session_migrate/formats/__init__.py @@ -6,9 +6,12 @@ codex, copilot, cursor, + grok, + kilo, kimi, muse, omp, + openhands, opencode, pi, qwen, @@ -21,9 +24,12 @@ "codex", "copilot", "cursor", + "grok", + "kilo", "kimi", "muse", "omp", + "openhands", "opencode", "pi", "qwen", diff --git a/src/session_migrate/formats/grok.py b/src/session_migrate/formats/grok.py new file mode 100644 index 0000000..633a7f3 --- /dev/null +++ b/src/session_migrate/formats/grok.py @@ -0,0 +1,524 @@ +"""Grok 1.0.5 local ACP-update session adapter.""" + +from __future__ import annotations + +import hashlib +import json +import os +import urllib.parse +import uuid +from collections import Counter +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from session_migrate.errors import JsonlError, SessionMigrateError +from session_migrate.formats.common import content_text, portable_data_image, string, valid_rfc3339 +from session_migrate.jsonl import DEFAULT_MAX_RECORDS, DEFAULT_MAX_TOTAL_BYTES +from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session + +PINNED_GROK_VERSION = "1.0.5" +PINNED_GROK_LINUX_X64_BYTES = 166_854_368 +PINNED_GROK_LINUX_X64_SHA256 = ( + "9ba87444e1819e8f6104adbbf4676a870c204380aa5c3e1c38a926c4ea677238" +) +GROK_BUNDLE_SCHEMA = "session-migrate.grok.v1" +MAX_BUNDLE_BYTES = DEFAULT_MAX_TOTAL_BYTES +MAX_UPDATES = DEFAULT_MAX_RECORDS + + +@dataclass(frozen=True, slots=True) +class ParsedGrokBundle: + summary: dict[str, Any] + updates: tuple[dict[str, Any], ...] + + +def serialize( + session: Session, + *, + session_id: str, + cwd: Path, + cli_version: str = PINNED_GROK_VERSION, + model: str | None = None, + timestamp: str | None = None, + title: str | None = None, +) -> tuple[bytes, dict[str, int]]: + """Serialize portable history into Grok's summary + ACP update contract.""" + + canonical_id = _uuid(session_id, "Grok target session ID") + started = valid_rfc3339(timestamp) or valid_rfc3339(session.started_at) or _utc_now() + unix_timestamp = int(datetime.fromisoformat(started.replace("Z", "+00:00")).timestamp()) + updates: list[dict[str, Any]] = [] + dropped: Counter[str] = Counter() + seen_calls: set[str] = set() + seen_results: set[str] = set() + tool_names: dict[str, str] = {} + message_count = 0 + first_user = "" + + def append(update: dict[str, Any]) -> None: + updates.append( + { + "timestamp": unix_timestamp, + "method": "session/update", + "params": {"sessionId": canonical_id, "update": update}, + } + ) + + for event in session.events: + if event.kind == EventKind.MESSAGE and event.role in {Role.USER, Role.ASSISTANT}: + if not event.text: + continue + kind = "user_message_chunk" if event.role == Role.USER else "agent_message_chunk" + append({"sessionUpdate": kind, "content": {"type": "text", "text": event.text}}) + message_count += 1 + if event.role == Role.USER and not first_user: + first_user = event.text + if event.payload.get("ui_only_projection") is True: + dropped["message:ui_only_projection"] += 1 + continue + + if event.kind == EventKind.CONTEXT and event.role == Role.USER: + image = portable_data_image(event.payload.get("image_url")) + if event.payload.get("block_type") != "image" or image is None: + dropped["context:image"] += 1 + continue + media_type, encoded = image + append( + { + "sessionUpdate": "user_message_chunk", + "content": { + "type": "image", + "data": encoded, + "mimeType": media_type, + "uri": f"data:{media_type};base64,{encoded}", + }, + } + ) + continue + + if event.kind == EventKind.TOOL_CALL: + call_id = event.tool_call_id or f"call_session_migrate_{uuid.uuid4().hex}" + if not event.tool_call_id: + dropped["tool_call:missing_id"] += 1 + if call_id in seen_calls: + dropped["tool_call:duplicate_id"] += 1 + seen_calls.add(call_id) + name = event.tool_name or "unknown_tool" + if not event.tool_name: + dropped["tool_call:missing_name"] += 1 + arguments = event.payload.get("input", {}) + if not isinstance(arguments, dict): + arguments = {"input": arguments} + dropped["tool_call:non_object_input"] += 1 + if event.payload.get("namespace"): + dropped["tool_call:namespace"] += 1 + append( + { + "sessionUpdate": "tool_call", + "toolCallId": call_id, + "title": name, + "kind": "other", + "status": "pending", + "rawInput": arguments, + "locations": [], + } + ) + tool_names.setdefault(call_id, name) + continue + + if event.kind == EventKind.TOOL_RESULT: + call_id = event.tool_call_id or f"call_missing_{uuid.uuid4().hex}" + if not event.tool_call_id: + dropped["tool_result:missing_id"] += 1 + elif call_id not in seen_calls: + dropped["tool_result:orphan_id"] += 1 + if event.tool_call_id and call_id in seen_results: + dropped["tool_result:duplicate_id"] += 1 + if event.tool_call_id: + seen_results.add(call_id) + result_text = event.text or content_text(event.payload.get("content")) or "" + blocks = event.payload.get("content_blocks") + if isinstance(blocks, list): + unsupported = sum( + 1 + for block in blocks + if not isinstance(block, dict) or block.get("type") != "text" + ) + if unsupported: + dropped["tool_result:non_text_content"] += unsupported + append( + { + "sessionUpdate": "tool_call_update", + "toolCallId": call_id, + "title": tool_names.get(call_id) or event.tool_name or "unknown_tool", + "status": "failed" if event.payload.get("is_error") is True else "completed", + "content": [ + { + "type": "content", + "content": {"type": "text", "text": result_text}, + } + ], + "rawOutput": {"session_migrate_text": result_text}, + } + ) + continue + + if event.kind == EventKind.COMPACTION and event.text: + append( + { + "sessionUpdate": "user_message_chunk", + "content": { + "type": "text", + "text": f"[Imported conversation summary]\n{event.text}", + }, + } + ) + dropped["compaction:flattened"] += 1 + if event.payload.get("has_boundary_metadata") is True: + dropped["compaction:boundary_metadata"] += 1 + if event.payload.get("replacement_history_expanded") is True: + dropped["compaction:replacement_history_expanded"] += 1 + continue + + if event.kind == EventKind.THINKING: + dropped["thinking:private"] += 1 + if event.payload.get("encrypted_content") or event.payload.get("signature"): + dropped["thinking:provider_payload"] += 1 + continue + + dropped[_omission_key(event)] += 1 + + if not any( + item["params"]["update"]["sessionUpdate"] + in {"user_message_chunk", "agent_message_chunk", "tool_call"} + for item in updates + ): + raise SessionMigrateError("conversion produced no resumable conversation history") + summary = { + "info": {"id": canonical_id, "cwd": str(cwd)}, + "session_summary": first_user[:500], + "created_at": started, + "updated_at": started, + "num_messages": len(updates), + "num_chat_messages": message_count, + "current_model_id": model or session.model or "grok-build", + "chat_format_version": 1, + "last_active_at": started, + "generated_title": title or session.title, + "title_is_manual": bool(title or session.title), + } + bundle = { + "schema": GROK_BUNDLE_SCHEMA, + "cli_version": cli_version, + "summary": summary, + "updates": updates, + } + data = ( + json.dumps(bundle, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n" + ).encode() + validate_native_bytes(data, canonical_id) + return data, dict(sorted(dropped.items())) + + +def parse_session(path: Path) -> Session: + """Parse a native Grok session directory, summary, or update log.""" + + directory = _source_directory(path) + summary_path = directory / "summary.json" + updates_path = directory / "updates.jsonl" + summary_bytes = _read_bounded(summary_path) + updates_bytes = _read_bounded(updates_path) + try: + summary = json.loads(summary_bytes, object_pairs_hook=_unique_object) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise JsonlError("Grok summary.json is not valid UTF-8 JSON") from exc + if not isinstance(summary, dict): + raise JsonlError("Grok summary.json is not a JSON object") + info = summary.get("info") + if not isinstance(info, dict): + raise JsonlError("Grok summary is missing session info") + session_id = _uuid(info.get("id"), "Grok source session ID") + cwd_value = string(info.get("cwd")) + if not cwd_value: + raise JsonlError("Grok summary is missing its working directory") + records = _decode_updates(updates_bytes, session_id) + events: list[Event] = [] + for index, record in enumerate(records): + events.extend(_parse_update(record, index)) + digest = hashlib.sha256(summary_bytes + b"\0" + updates_bytes).hexdigest() + return Session( + source_format=AgentFormat.GROK, + source_path=directory.resolve(), + source_sha256=digest, + session_id=session_id, + cwd=Path(cwd_value), + started_at=valid_rfc3339(summary.get("created_at")), + cli_version=PINNED_GROK_VERSION, + model=string(summary.get("current_model_id")), + title=string(summary.get("generated_title")) or string(summary.get("session_summary")), + events=tuple(events), + raw_record_count=len(records) + 1, + model_provider="xai", + ) + + +parse = parse_session + + +def validate_native_bytes(data: bytes, session_id: str) -> ParsedGrokBundle: + if not data or len(data) > MAX_BUNDLE_BYTES: + raise SessionMigrateError("generated Grok bundle is empty or exceeds the safety limit") + try: + value = json.loads(data, object_pairs_hook=_unique_object) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise SessionMigrateError("generated Grok bundle is not valid UTF-8 JSON") from exc + if not isinstance(value, dict) or value.get("schema") != GROK_BUNDLE_SCHEMA: + raise SessionMigrateError("generated Grok bundle has an unsupported schema") + summary = value.get("summary") + updates = value.get("updates") + if not isinstance(summary, dict) or not isinstance(updates, list): + raise SessionMigrateError("generated Grok bundle is missing summary or updates") + info = summary.get("info") + if not isinstance(info, dict) or _uuid(info.get("id"), "Grok summary ID") != _uuid( + session_id, "Grok target session ID" + ): + raise SessionMigrateError("generated Grok bundle session linkage is invalid") + if not string(info.get("cwd")) or not valid_rfc3339(summary.get("created_at")): + raise SessionMigrateError("generated Grok summary metadata is invalid") + if not isinstance(summary.get("num_messages"), int) or summary["num_messages"] != len( + updates + ): + raise SessionMigrateError("generated Grok summary count is inconsistent") + if not updates or len(updates) > MAX_UPDATES: + raise SessionMigrateError("generated Grok bundle has no resumable updates") + encoded = b"".join( + (json.dumps(item, separators=(",", ":")) + "\n").encode() for item in updates + ) + _decode_updates(encoded, _uuid(session_id, "Grok target session ID")) + return ParsedGrokBundle(dict(summary), tuple(dict(item) for item in updates)) + + +def native_record_count(data: bytes) -> int: + value = json.loads(data) + updates = value.get("updates", []) if isinstance(value, dict) else [] + return 1 + len(updates) if isinstance(updates, list) else 0 + + +def native_files(data: bytes, session_id: str) -> tuple[bytes, bytes]: + parsed = validate_native_bytes(data, session_id) + summary = (json.dumps(parsed.summary, ensure_ascii=False, indent=2) + "\n").encode() + updates = b"".join( + (json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n").encode() + for item in parsed.updates + ) + return summary, updates + + +def encode_cwd(cwd: Path) -> str: + encoded = urllib.parse.quote(str(cwd), safe="") + if len(encoded.encode()) > 255: + raise SessionMigrateError("Grok target working directory is too long to encode safely") + return encoded + + +def session_relative_path(cwd: Path, session_id: str) -> Path: + return Path("sessions") / encode_cwd(cwd) / _uuid(session_id, "Grok target session ID") + + +def grok_home(*, environ: dict[str, str] | None = None) -> Path: + values = os.environ if environ is None else environ + configured = values.get("GROK_HOME") + return Path(configured).expanduser() if configured else Path.home() / ".grok" + + +def _parse_update(record: dict[str, Any], index: int) -> list[Event]: + update = record["params"]["update"] + kind = update["sessionUpdate"] + timestamp = datetime.fromtimestamp(record["timestamp"], UTC).isoformat().replace("+00:00", "Z") + provenance = Provenance(index, f"grok.{kind}") + if kind in {"user_message_chunk", "agent_message_chunk"}: + role = Role.USER if kind.startswith("user") else Role.ASSISTANT + content = update.get("content") + if content.get("type") == "text": + return [ + Event( + EventKind.MESSAGE, + provenance, + role=role, + text=content["text"], + timestamp=timestamp, + ) + ] + if content.get("type") == "image" and role == Role.USER: + image_url = string(content.get("uri")) + if not image_url: + image_url = f"data:{content['mimeType']};base64,{content['data']}" + return [ + Event( + EventKind.CONTEXT, + provenance, + role=role, + timestamp=timestamp, + payload={"block_type": "image", "image_url": image_url}, + ) + ] + if kind == "agent_thought_chunk": + return [ + Event( + EventKind.OPAQUE, + provenance, + role=Role.ASSISTANT, + timestamp=timestamp, + payload={"reason": "grok_private_thinking"}, + ) + ] + if kind == "tool_call": + return [ + Event( + EventKind.TOOL_CALL, + provenance, + role=Role.ASSISTANT, + timestamp=timestamp, + tool_name=string(update.get("title")), + tool_call_id=string(update.get("toolCallId")), + payload={"input": update.get("rawInput", {})}, + ) + ] + if kind == "tool_call_update" and update.get("status") in {"completed", "failed"}: + raw = update.get("rawOutput") + text = raw.get("session_migrate_text") if isinstance(raw, dict) else None + if not isinstance(text, str): + text = _tool_update_text(update) + return [ + Event( + EventKind.TOOL_RESULT, + provenance, + role=Role.TOOL, + timestamp=timestamp, + tool_name=string(update.get("title")), + tool_call_id=string(update.get("toolCallId")), + text=text, + payload={"is_error": update.get("status") == "failed"}, + ) + ] + return [ + Event( + EventKind.OPAQUE, + provenance, + timestamp=timestamp, + payload={"reason": f"grok_{kind}"}, + ) + ] + + +def _decode_updates(data: bytes, session_id: str) -> list[dict[str, Any]]: + if len(data) > MAX_BUNDLE_BYTES: + raise JsonlError("Grok updates.jsonl exceeds the input safety limit") + records = [] + for line_number, line in enumerate(data.splitlines(), start=1): + if not line.strip(): + continue + if len(records) >= MAX_UPDATES: + raise JsonlError("Grok update log exceeds the record limit") + try: + value = json.loads(line, object_pairs_hook=_unique_object) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise JsonlError(f"Grok update line {line_number} is not valid JSON") from exc + if not isinstance(value, dict) or value.get("method") != "session/update": + raise JsonlError("Grok update envelope is malformed") + params = value.get("params") + update = params.get("update") if isinstance(params, dict) else None + if ( + not isinstance(params, dict) + or params.get("sessionId") != session_id + or not isinstance(update, dict) + or not string(update.get("sessionUpdate")) + or not isinstance(value.get("timestamp"), int) + ): + raise JsonlError("Grok update linkage or metadata is invalid") + _validate_update(update) + records.append(value) + if not records: + raise JsonlError("Grok update log is empty") + return records + + +def _validate_update(update: dict[str, Any]) -> None: + kind = update["sessionUpdate"] + if kind in {"user_message_chunk", "agent_message_chunk", "agent_thought_chunk"}: + content = update.get("content") + if not isinstance(content, dict) or content.get("type") not in {"text", "image"}: + raise JsonlError("Grok message update is malformed") + if content["type"] == "text" and not isinstance(content.get("text"), str): + raise JsonlError("Grok text update is malformed") + if content["type"] == "image" and ( + portable_data_image(content.get("uri")) is None + and not (string(content.get("data")) and string(content.get("mimeType"))) + ): + raise JsonlError("Grok image update is malformed") + elif kind in {"tool_call", "tool_call_update"} and not string( + update.get("toolCallId") + ): + raise JsonlError("Grok tool update is malformed") + + +def _tool_update_text(update: dict[str, Any]) -> str: + result = [] + for item in update.get("content", []) if isinstance(update.get("content"), list) else []: + content = item.get("content") if isinstance(item, dict) else None + if isinstance(content, dict) and content.get("type") == "text": + result.append(str(content.get("text", ""))) + if result: + return "".join(result) + raw = update.get("rawOutput") + return json.dumps(raw, ensure_ascii=False, separators=(",", ":")) if raw is not None else "" + + +def _source_directory(path: Path) -> Path: + candidate = path.expanduser() + directory = candidate if candidate.is_dir() else candidate.parent + if not (directory / "summary.json").is_file() or not (directory / "updates.jsonl").is_file(): + raise SessionMigrateError( + "Grok source must be a session directory containing summary.json and updates.jsonl" + ) + return directory + + +def _read_bounded(path: Path) -> bytes: + if path.is_symlink() or not path.is_file(): + raise JsonlError("Grok source path is not a regular file") + try: + data = path.read_bytes() + except OSError as exc: + raise JsonlError(f"cannot read Grok session file: {exc.strerror or exc}") from exc + if not data or len(data) > MAX_BUNDLE_BYTES: + raise JsonlError("Grok session file is empty or exceeds the input safety limit") + return data + + +def _uuid(value: Any, label: str) -> str: + try: + return str(uuid.UUID(str(value))) + except (ValueError, TypeError, AttributeError) as exc: + raise SessionMigrateError(f"{label} is not a valid UUID") from exc + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON member") + result[key] = value + return result + + +def _omission_key(event: Event) -> str: + if event.kind == EventKind.OPAQUE: + return string(event.payload.get("reason")) or "opaque" + return event.kind.value + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") diff --git a/src/session_migrate/formats/kilo.py b/src/session_migrate/formats/kilo.py new file mode 100644 index 0000000..f5cc3c5 --- /dev/null +++ b/src/session_migrate/formats/kilo.py @@ -0,0 +1,87 @@ +"""Kilo Code 7.5.0 session import/export adapter. + +Kilo exposes a supported ``import``/``export`` JSON contract. The contract is +compatible with the OpenCode bundle lineage, but Kilo remains a distinct +format: it has its own binary pin, source identity, installation command, and +native store. +""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +from session_migrate.formats import opencode +from session_migrate.jsonl import file_sha256 +from session_migrate.model import AgentFormat, Session + +PINNED_KILO_VERSION = "7.5.0" +PINNED_KILO_LINUX_X64_BYTES = 145_118_408 +PINNED_KILO_LINUX_X64_SHA256 = ( + "ede061eb9178d0158ac66baa81619e2bf66859041d20d0a014798d38ddc7c1ce" +) +KILO_NATIVE_IMPORT_SUPPORTED = True +MAX_NATIVE_BYTES = opencode.MAX_NATIVE_BYTES + +session_id_from_uuid = opencode.session_id_from_uuid + + +def serialize( + session: Session, + *, + session_id: str, + cwd: Path, + cli_version: str = PINNED_KILO_VERSION, + provider_id: str = "anthropic", + model_id: str | None = None, + agent: str = "build", + timestamp: str | None = None, + title: str | None = None, +) -> tuple[bytes, dict[str, int]]: + """Serialize portable history as Kilo's supported import bundle.""" + + return opencode.serialize( + session, + session_id=session_id, + cwd=cwd, + cli_version=cli_version, + provider_id=provider_id, + model_id=model_id, + agent=agent, + timestamp=timestamp, + title=title, + ) + + +def parse(path: Path) -> opencode.ParsedOpenCodeSession: + """Parse a bundle produced by ``kilo export``.""" + + return opencode.parse_import(path) + + +def parse_session(path: Path) -> Session: + """Project a Kilo export bundle into the portable event model.""" + + parsed = parse(path) + base = opencode.parse_session(path) + return replace( + base, + source_format=AgentFormat.KILO, + source_path=path.resolve(), + source_sha256=file_sha256(path), + cli_version=parsed.cli_version, + ) + + +def validate_native_bytes(data: bytes, session_id: str) -> None: + """Validate Kilo's supported import document without invoking the CLI.""" + + opencode.validate_native_bytes(data, session_id) + + +def native_record_count(data: bytes) -> int: + """Count the session header, messages, and parts in an import bundle.""" + + value = opencode._decode_import_bundle(data) + messages = value.get("messages", []) + return 1 + len(messages) + sum(len(item.get("parts", [])) for item in messages) diff --git a/src/session_migrate/formats/openhands.py b/src/session_migrate/formats/openhands.py new file mode 100644 index 0000000..137e2ee --- /dev/null +++ b/src/session_migrate/formats/openhands.py @@ -0,0 +1,741 @@ +"""OpenHands CLI 1.16.0 event-log session adapter. + +OpenHands resumes a conversation from ordered JSON event files below +``~/.openhands/conversations//events``. ``base_state.json`` is a +derived runtime cache: the pinned CLI rebuilds it when only the event log is +present, so migration never copies credentials, provider settings, or cached +runtime state. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import uuid +from collections import Counter +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from session_migrate.errors import JsonlError, SessionMigrateError +from session_migrate.formats.common import content_text, portable_data_image, string, valid_rfc3339 +from session_migrate.jsonl import DEFAULT_MAX_RECORDS, DEFAULT_MAX_TOTAL_BYTES +from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session + +PINNED_OPENHANDS_VERSION = "1.16.0" +PINNED_OPENHANDS_LINUX_X64_BYTES = 88_139_576 +PINNED_OPENHANDS_LINUX_X64_SHA256 = ( + "cb04ee2da91c698733d5201c55cbc08d81dccc9d64b666275abf68a4e0c590e3" +) +OPENHANDS_BUNDLE_SCHEMA = "session-migrate.openhands.v1" +MAX_BUNDLE_BYTES = DEFAULT_MAX_TOTAL_BYTES +MAX_EVENTS = DEFAULT_MAX_RECORDS +MAX_JSON_DEPTH = 96 +MAX_JSON_NODES = 1_000_000 +_EVENT_NAME = re.compile(r"event-(\d{5})-([0-9a-f-]{36})\.json$") + + +@dataclass(frozen=True, slots=True) +class ParsedOpenHandsBundle: + session_id: str + cwd: Path + cli_version: str + title: str | None + events: tuple[dict[str, Any], ...] + + +def serialize( + session: Session, + *, + session_id: str, + cwd: Path, + cli_version: str = PINNED_OPENHANDS_VERSION, + model: str | None = None, + timestamp: str | None = None, + title: str | None = None, +) -> tuple[bytes, dict[str, int]]: + """Serialize portable history as an installable OpenHands event bundle.""" + + canonical_id = _uuid(session_id, "OpenHands target session ID") + started = valid_rfc3339(timestamp) or valid_rfc3339(session.started_at) or _utc_now() + clock = datetime.fromisoformat(started.replace("Z", "+00:00")).astimezone(UTC) + dropped: Counter[str] = Counter() + records: list[dict[str, Any]] = [] + seen_calls: set[str] = set() + seen_results: set[str] = set() + action_ids: dict[str, str] = {} + tool_names: dict[str, str] = {} + + def next_record(kind: str, source: str, **fields: Any) -> dict[str, Any]: + nonlocal clock + value = { + "id": str(uuid.uuid4()), + "timestamp": clock.replace(tzinfo=None).isoformat(timespec="microseconds"), + "source": source, + **fields, + "kind": kind, + } + clock += timedelta(microseconds=1) + records.append(value) + return value + + next_record( + "SystemPromptEvent", + "agent", + system_prompt={ + "cache_prompt": False, + "type": "text", + "text": "Imported portable session history.", + }, + tools=[], + dynamic_context={"cache_prompt": False, "type": "text", "text": ""}, + ) + + for event in session.events: + if event.kind == EventKind.MESSAGE and event.role in {Role.USER, Role.ASSISTANT}: + if not event.text: + continue + role = event.role.value + next_record( + "MessageEvent", + "user" if role == "user" else "agent", + llm_message={ + "role": role, + "content": [_text_block(event.text)], + "thinking_blocks": [], + }, + activated_skills=[], + extended_content=[], + ) + if event.payload.get("ui_only_projection") is True: + dropped["message:ui_only_projection"] += 1 + continue + + if event.kind == EventKind.CONTEXT and event.role == Role.USER: + if event.payload.get("block_type") != "image": + dropped[_omission_key(event)] += 1 + continue + image = portable_data_image(event.payload.get("image_url")) + if image is None: + dropped["context:image"] += 1 + continue + media_type, encoded = image + next_record( + "MessageEvent", + "user", + llm_message={ + "role": "user", + "content": [ + { + "type": "image", + "image_urls": [f"data:{media_type};base64,{encoded}"], + } + ], + "thinking_blocks": [], + }, + activated_skills=[], + extended_content=[], + ) + continue + + if event.kind == EventKind.TOOL_CALL: + call_id = event.tool_call_id or f"call_session_migrate_{uuid.uuid4().hex}" + if not event.tool_call_id: + dropped["tool_call:missing_id"] += 1 + if call_id in seen_calls: + dropped["tool_call:duplicate_id"] += 1 + seen_calls.add(call_id) + name = event.tool_name or "unknown_tool" + if not event.tool_name: + dropped["tool_call:missing_name"] += 1 + arguments = event.payload.get("input", {}) + if not isinstance(arguments, dict): + arguments = {"input": arguments} + dropped["tool_call:non_object_input"] += 1 + if event.payload.get("namespace"): + dropped["tool_call:namespace"] += 1 + native = next_record( + "ActionEvent", + "agent", + thought=[], + thinking_blocks=[], + action={ + "command": json.dumps(arguments, ensure_ascii=False, separators=(",", ":")), + "kind": "TerminalAction", + }, + tool_name=name, + tool_call_id=call_id, + tool_call={ + "id": call_id, + "name": name, + "arguments": json.dumps( + arguments, ensure_ascii=False, separators=(",", ":") + ), + "origin": "completion", + }, + security_risk="LOW", + summary=f"Imported {name} call", + ) + action_ids.setdefault(call_id, native["id"]) + tool_names.setdefault(call_id, name) + continue + + if event.kind == EventKind.TOOL_RESULT: + call_id = event.tool_call_id or f"call_missing_{uuid.uuid4().hex}" + if not event.tool_call_id: + dropped["tool_result:missing_id"] += 1 + elif call_id not in seen_calls: + dropped["tool_result:orphan_id"] += 1 + if event.tool_call_id and call_id in seen_results: + dropped["tool_result:duplicate_id"] += 1 + if event.tool_call_id: + seen_results.add(call_id) + content = _tool_result_content(event, dropped) + next_record( + "ObservationEvent", + "environment", + tool_name=event.tool_name or tool_names.get(call_id) or "unknown_tool", + tool_call_id=call_id, + observation={ + "content": content, + "is_error": event.payload.get("is_error") is True, + "command": "imported portable tool result", + "exit_code": 1 if event.payload.get("is_error") is True else 0, + "timeout": False, + "metadata": {}, + "kind": "TerminalObservation", + }, + action_id=action_ids.get(call_id, str(uuid.uuid4())), + ) + continue + + if event.kind == EventKind.COMPACTION and event.text: + next_record( + "Condensation", + "environment", + forgotten_event_ids=[], + summary=event.text, + ) + if event.payload.get("has_boundary_metadata") is True: + dropped["compaction:boundary_metadata"] += 1 + if event.payload.get("replacement_history_expanded") is True: + dropped["compaction:replacement_history_expanded"] += 1 + continue + + if event.kind == EventKind.THINKING: + dropped["thinking:private"] += 1 + if event.payload.get("encrypted_content") or event.payload.get("signature"): + dropped["thinking:provider_payload"] += 1 + continue + + dropped[_omission_key(event)] += 1 + + if not any(record["kind"] in {"MessageEvent", "ActionEvent"} for record in records[1:]): + raise SessionMigrateError("conversion produced no resumable conversation history") + bundle = { + "schema": OPENHANDS_BUNDLE_SCHEMA, + "session_id": canonical_id, + "cwd": str(cwd), + "cli_version": cli_version, + "model": model or session.model, + "title": title or session.title, + "events": records, + } + data = ( + json.dumps(bundle, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n" + ).encode() + validate_native_bytes(data, canonical_id) + return data, dict(sorted(dropped.items())) + + +def parse_session(path: Path) -> Session: + """Parse one native OpenHands conversation directory or events directory.""" + + conversation, events_dir = _source_paths(path) + entries = _read_event_files(events_dir) + events: list[Event] = [] + for index, (_, value) in enumerate(entries): + events.extend(_parse_event(value, index)) + first_timestamp = string(entries[0][1].get("timestamp")) if entries else None + digest = hashlib.sha256() + for name, value in entries: + digest.update(name.encode()) + digest.update(b"\0") + digest.update(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()) + digest.update(b"\0") + model, cwd = _derived_state_metadata(conversation) + return Session( + source_format=AgentFormat.OPENHANDS, + source_path=conversation.resolve(), + source_sha256=digest.hexdigest(), + session_id=_uuid(conversation.name, "OpenHands conversation directory"), + cwd=cwd, + started_at=_portable_timestamp(first_timestamp), + cli_version=PINNED_OPENHANDS_VERSION, + model=model, + title=None, + events=tuple(events), + raw_record_count=len(entries), + model_provider=model.split("/", 1)[0] if model and "/" in model else None, + ) + + +parse = parse_session + + +def validate_native_bytes(data: bytes, session_id: str) -> ParsedOpenHandsBundle: + """Validate a generated bundle without touching OpenHands state.""" + + if not data or len(data) > MAX_BUNDLE_BYTES: + raise SessionMigrateError("generated OpenHands bundle is empty or exceeds the safety limit") + try: + value = json.loads(data, object_pairs_hook=_unique_object) + _validate_json_shape(value) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError) as exc: + raise SessionMigrateError("generated OpenHands bundle is not valid UTF-8 JSON") from exc + if not isinstance(value, dict) or value.get("schema") != OPENHANDS_BUNDLE_SCHEMA: + raise SessionMigrateError("generated OpenHands bundle has an unsupported schema") + canonical_id = _uuid(value.get("session_id"), "generated OpenHands session ID") + if canonical_id != _uuid(session_id, "OpenHands target session ID"): + raise SessionMigrateError("generated OpenHands bundle session linkage is invalid") + cwd = string(value.get("cwd")) + version = string(value.get("cli_version")) + events = value.get("events") + if not cwd or "\x00" in cwd or not version or not isinstance(events, list): + raise SessionMigrateError("generated OpenHands bundle has invalid metadata") + if not events or len(events) > MAX_EVENTS or not all(isinstance(item, dict) for item in events): + raise SessionMigrateError("generated OpenHands bundle has invalid events") + for index, event in enumerate(events): + _validate_event(event, index) + if events[0].get("kind") != "SystemPromptEvent": + raise SessionMigrateError("generated OpenHands history must start with a system event") + if not any(event.get("kind") in {"MessageEvent", "ActionEvent"} for event in events[1:]): + raise SessionMigrateError( + "generated OpenHands bundle has no resumable conversation history" + ) + return ParsedOpenHandsBundle( + session_id=canonical_id, + cwd=Path(cwd), + cli_version=version, + title=string(value.get("title")), + events=tuple(dict(event) for event in events), + ) + + +def native_record_count(data: bytes) -> int: + value = json.loads(data) + events = value.get("events", []) if isinstance(value, dict) else [] + return len(events) if isinstance(events, list) else 0 + + +def native_files(data: bytes, session_id: str) -> tuple[tuple[str, bytes], ...]: + parsed = validate_native_bytes(data, session_id) + files = [] + for index, event in enumerate(parsed.events): + name = f"event-{index:05d}-{event['id']}.json" + content = (json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n").encode() + files.append((name, content)) + return tuple(files) + + +def session_relative_path(session_id: str) -> Path: + return Path(_uuid(session_id, "OpenHands target session ID").replace("-", "")) / "events" + + +def conversations_home(*, environ: dict[str, str] | None = None) -> Path: + values = os.environ if environ is None else environ + configured = values.get("OPENHANDS_CONVERSATIONS_DIR") + return Path(configured).expanduser() if configured else Path.home() / ".openhands/conversations" + + +def _source_paths(path: Path) -> tuple[Path, Path]: + candidate = path.expanduser() + if candidate.is_file() and _EVENT_NAME.fullmatch(candidate.name): + events = candidate.parent + conversation = events.parent + elif candidate.is_dir() and candidate.name == "events": + events = candidate + conversation = candidate.parent + elif candidate.is_dir() and (candidate / "events").is_dir(): + conversation = candidate + events = candidate / "events" + else: + raise SessionMigrateError( + "OpenHands source must be a conversation directory containing an events directory" + ) + _uuid(conversation.name, "OpenHands conversation directory") + return conversation, events + + +def _read_event_files(events_dir: Path) -> list[tuple[str, dict[str, Any]]]: + entries: list[tuple[str, dict[str, Any]]] = [] + total = 0 + paths = sorted(events_dir.glob("event-*.json")) + if not paths or len(paths) > MAX_EVENTS: + raise JsonlError("OpenHands event log is empty or exceeds the record limit") + for expected, path in enumerate(paths): + match = _EVENT_NAME.fullmatch(path.name) + if not match or int(match.group(1)) != expected or path.is_symlink() or not path.is_file(): + raise JsonlError("OpenHands event filenames are invalid or non-contiguous") + try: + data = path.read_bytes() + except OSError as exc: + raise JsonlError(f"cannot read OpenHands event: {exc.strerror or exc}") from exc + total += len(data) + if total > MAX_BUNDLE_BYTES: + raise JsonlError("OpenHands event log exceeds the input safety limit") + try: + value = json.loads(data, object_pairs_hook=_unique_object) + _validate_json_shape(value) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise JsonlError("OpenHands event is not valid UTF-8 JSON") from exc + if not isinstance(value, dict) or value.get("id") != match.group(2): + raise JsonlError("OpenHands event filename and metadata disagree") + _validate_event(value, expected) + entries.append((path.name, value)) + if entries[0][1].get("kind") != "SystemPromptEvent": + raise JsonlError("OpenHands event log must start with a system event") + return entries + + +def _parse_event(value: dict[str, Any], index: int) -> list[Event]: + kind = str(value["kind"]) + timestamp = _portable_timestamp(value.get("timestamp")) + provenance = Provenance(index, f"openhands.{kind}", str(value["id"])) + if kind == "SystemPromptEvent": + return [ + Event( + kind=EventKind.OPAQUE, + role=Role.SYSTEM, + payload={"reason": "openhands_system_prompt"}, + timestamp=timestamp, + provenance=provenance, + ) + ] + if kind == "MessageEvent": + message = value["llm_message"] + role = Role.USER if message["role"] == "user" else Role.ASSISTANT + events = _content_events(message.get("content"), role, timestamp, provenance) + if message.get("thinking_blocks"): + events.append( + Event( + kind=EventKind.OPAQUE, + role=Role.ASSISTANT, + payload={"reason": "openhands_private_thinking"}, + timestamp=timestamp, + provenance=provenance, + ) + ) + if value.get("activated_skills") or value.get("extended_content"): + events.append( + Event( + kind=EventKind.OPAQUE, + role=role, + payload={"reason": "openhands_message_runtime_metadata"}, + timestamp=timestamp, + provenance=provenance, + ) + ) + return events + if kind == "ActionEvent": + events: list[Event] = [] + if value.get("thought") or value.get("thinking_blocks"): + events.append( + Event( + kind=EventKind.OPAQUE, + role=Role.ASSISTANT, + payload={"reason": "openhands_private_thinking"}, + timestamp=timestamp, + provenance=provenance, + ) + ) + call = value.get("tool_call") + arguments: Any = value.get("action", {}) + if isinstance(call, dict) and isinstance(call.get("arguments"), str): + try: + arguments = json.loads(call["arguments"]) + except json.JSONDecodeError: + arguments = {"input": call["arguments"]} + events.append( + Event( + kind=EventKind.TOOL_CALL, + role=Role.ASSISTANT, + tool_name=string(value.get("tool_name")), + tool_call_id=string(value.get("tool_call_id")), + payload={"input": arguments}, + timestamp=timestamp, + provenance=provenance, + ) + ) + return events + if kind == "ObservationEvent": + observation = value["observation"] + content = observation.get("content") + blocks = _portable_result_blocks(content) + return [ + Event( + kind=EventKind.TOOL_RESULT, + role=Role.TOOL, + text=content_text(content), + tool_name=string(value.get("tool_name")), + tool_call_id=string(value.get("tool_call_id")), + payload={ + "content_blocks": blocks, + "is_error": observation.get("is_error") is True, + }, + timestamp=timestamp, + provenance=provenance, + ) + ] + if kind == "Condensation": + return [ + Event( + kind=EventKind.COMPACTION, + role=Role.SYSTEM, + text=string(value.get("summary")), + payload={"source_subtype": "openhands_condensation"}, + timestamp=timestamp, + provenance=provenance, + ) + ] + return [ + Event( + kind=EventKind.OPAQUE, + payload={"reason": f"openhands_{kind}"}, + timestamp=timestamp, + provenance=provenance, + ) + ] + + +def _validate_event(value: dict[str, Any], index: int) -> None: + event_id = _uuid(value.get("id"), f"OpenHands event {index} id") + del event_id + if not _native_timestamp(value.get("timestamp")): + raise SessionMigrateError(f"OpenHands event {index} has an invalid timestamp") + kind = string(value.get("kind")) + source = string(value.get("source")) + if not kind or source not in {"agent", "user", "environment"}: + raise SessionMigrateError(f"OpenHands event {index} has invalid metadata") + if kind == "SystemPromptEvent": + if source != "agent" or not _text_content(value.get("system_prompt")): + raise SessionMigrateError("OpenHands system event is malformed") + elif kind == "MessageEvent": + message = value.get("llm_message") + if not isinstance(message, dict) or message.get("role") not in {"user", "assistant"}: + raise SessionMigrateError("OpenHands message event is malformed") + _validate_content(message.get("content")) + elif kind == "ActionEvent": + if not string(value.get("tool_name")) or not string(value.get("tool_call_id")): + raise SessionMigrateError("OpenHands action event is malformed") + if not isinstance(value.get("action"), dict) or not isinstance( + value.get("tool_call"), dict + ): + raise SessionMigrateError("OpenHands action event is malformed") + elif kind == "ObservationEvent": + if not string(value.get("tool_name")) or not string(value.get("tool_call_id")): + raise SessionMigrateError("OpenHands observation event is malformed") + observation = value.get("observation") + if not isinstance(observation, dict) or not isinstance(observation.get("is_error"), bool): + raise SessionMigrateError("OpenHands observation event is malformed") + _validate_content(observation.get("content")) + elif kind == "Condensation" and ( + not string(value.get("summary")) + or not isinstance(value.get("forgotten_event_ids"), list) + ): + raise SessionMigrateError("OpenHands condensation event is malformed") + + +def _validate_content(content: Any) -> None: + if not isinstance(content, list) or not content: + raise SessionMigrateError("OpenHands content is empty or malformed") + for block in content: + if not isinstance(block, dict): + raise SessionMigrateError("OpenHands content block is malformed") + block_type = block.get("type") + if block_type == "text": + if not isinstance(block.get("text"), str): + raise SessionMigrateError("OpenHands text block is malformed") + elif block_type == "image": + urls = block.get("image_urls") + if not isinstance(urls, list) or not urls or not all( + portable_data_image(item) is not None for item in urls + ): + raise SessionMigrateError("OpenHands image block is malformed") + else: + raise SessionMigrateError("OpenHands content block type is unsupported") + + +def _content_events( + content: Any, + role: Role, + timestamp: str | None, + provenance: Provenance, +) -> list[Event]: + events: list[Event] = [] + for block_index, block in enumerate(content if isinstance(content, list) else []): + block_provenance = Provenance( + provenance.record_index, + provenance.record_type, + provenance.source_id, + block_index, + ) + if block.get("type") == "text" and block.get("text"): + events.append( + Event( + kind=EventKind.MESSAGE, + role=role, + text=block["text"], + timestamp=timestamp, + provenance=block_provenance, + ) + ) + elif block.get("type") == "image": + for image_url in block.get("image_urls", []): + events.append( + Event( + kind=EventKind.CONTEXT, + role=role, + payload={"block_type": "image", "image_url": image_url}, + timestamp=timestamp, + provenance=block_provenance, + ) + ) + return events + + +def _tool_result_content(event: Event, dropped: Counter[str]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + content = event.payload.get("content_blocks") or event.payload.get("content") + if isinstance(content, list): + for block in content: + if not isinstance(block, dict): + dropped["tool_result:opaque"] += 1 + continue + if block.get("type") == "text" and isinstance(block.get("text"), str): + result.append(_text_block(block["text"])) + elif block.get("type") == "image": + image = portable_data_image(block.get("image_url")) + if image is None: + dropped["tool_result:opaque"] += 1 + else: + media_type, encoded = image + result.append( + {"type": "image", "image_urls": [f"data:{media_type};base64,{encoded}"]} + ) + else: + dropped["tool_result:opaque"] += 1 + if not result: + text = event.text or content_text(content) or "" + result.append(_text_block(text)) + return result + + +def _portable_result_blocks(content: Any) -> list[dict[str, Any]]: + blocks: list[dict[str, Any]] = [] + if not isinstance(content, list): + return blocks + for block in content: + if block.get("type") == "text": + blocks.append({"type": "text", "text": block.get("text", "")}) + elif block.get("type") == "image": + for image_url in block.get("image_urls", []): + blocks.append({"type": "image", "image_url": image_url}) + return blocks + + +def _text_block(text: str) -> dict[str, Any]: + return {"cache_prompt": False, "type": "text", "text": text} + + +def _text_content(value: Any) -> str | None: + if isinstance(value, dict) and value.get("type") == "text": + return string(value.get("text")) + return None + + +def _derived_state_metadata(conversation: Path) -> tuple[str | None, Path | None]: + path = conversation / "base_state.json" + if not path.is_file() or path.is_symlink(): + return None, None + try: + value = json.loads(path.read_bytes()) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None, None + if not isinstance(value, dict): + return None, None + agent = value.get("agent") + llm = agent.get("llm") if isinstance(agent, dict) else None + model = string(llm.get("model")) if isinstance(llm, dict) else None + workspace = value.get("workspace") + cwd_value = None + if isinstance(workspace, dict): + cwd_value = string(workspace.get("working_dir")) or string(workspace.get("cwd")) + return model, Path(cwd_value) if cwd_value else None + + +def _portable_timestamp(value: Any) -> str | None: + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _native_timestamp(value: Any) -> bool: + if not isinstance(value, str): + return False + try: + datetime.fromisoformat(value) + except ValueError: + return False + return True + + +def _uuid(value: Any, label: str) -> str: + try: + return str(uuid.UUID(str(value))) + except (ValueError, TypeError, AttributeError) as exc: + raise SessionMigrateError(f"{label} is not a valid UUID") from exc + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON member") + result[key] = value + return result + + +def _validate_json_shape(value: Any) -> None: + nodes = 0 + stack: list[tuple[Any, int]] = [(value, 0)] + while stack: + current, depth = stack.pop() + nodes += 1 + if nodes > MAX_JSON_NODES or depth > MAX_JSON_DEPTH: + raise ValueError("JSON structure exceeds safety limit") + if isinstance(current, dict): + stack.extend((item, depth + 1) for item in current.values()) + elif isinstance(current, list): + stack.extend((item, depth + 1) for item in current) + elif isinstance(current, float) and not current.is_integer(): + raise ValueError("non-finite or fractional numeric metadata") + + +def _omission_key(event: Event) -> str: + if event.kind == EventKind.OPAQUE: + return string(event.payload.get("reason")) or "opaque" + return event.kind.value + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") diff --git a/src/session_migrate/model.py b/src/session_migrate/model.py index 2ba3b9d..fe5716c 100644 --- a/src/session_migrate/model.py +++ b/src/session_migrate/model.py @@ -22,6 +22,9 @@ class AgentFormat(StrEnum): MUSE = "muse" QWEN = "qwen" KIMI = "kimi" + GROK = "grok" + KILO = "kilo" + OPENHANDS = "openhands" class TargetFormat(StrEnum): @@ -39,6 +42,9 @@ class TargetFormat(StrEnum): MUSE = "muse" QWEN = "qwen" KIMI = "kimi" + GROK = "grok" + KILO = "kilo" + OPENHANDS = "openhands" class Role(StrEnum): diff --git a/tests/test_grok_format.py b/tests/test_grok_format.py new file mode 100644 index 0000000..6bde5ec --- /dev/null +++ b/tests/test_grok_format.py @@ -0,0 +1,210 @@ +import json +from pathlib import Path + +import pytest + +from session_migrate.errors import JsonlError, SessionMigrateError +from session_migrate.formats import grok +from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session + +SESSION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + + +def envelope(update: dict[str, object]) -> dict[str, object]: + return { + "timestamp": 1787745600, + "method": "session/update", + "params": {"sessionId": SESSION_ID, "update": update}, + } + + +def write_native_session(tmp_path: Path) -> Path: + session = tmp_path / "sessions" / "%2Ftmp%2Fgrok-project" / SESSION_ID + session.mkdir(parents=True) + summary = { + "info": {"id": SESSION_ID, "cwd": "/tmp/grok-project"}, + "session_summary": "GROK_USER", + "created_at": "2026-08-26T12:00:00Z", + "updated_at": "2026-08-26T12:00:00Z", + "num_messages": 7, + "num_chat_messages": 2, + "current_model_id": "grok-build", + "chat_format_version": 1, + "generated_title": "Synthetic Grok session", + } + updates = [ + envelope( + { + "sessionUpdate": "user_message_chunk", + "content": {"type": "text", "text": "GROK_USER"}, + } + ), + envelope( + { + "sessionUpdate": "user_message_chunk", + "content": { + "type": "image", + "data": "c3ludGhldGlj", + "mimeType": "image/png", + "uri": "data:image/png;base64,c3ludGhldGlj", + }, + } + ), + envelope( + { + "sessionUpdate": "agent_thought_chunk", + "content": {"type": "text", "text": "PRIVATE_GROK_THOUGHT"}, + } + ), + envelope( + { + "sessionUpdate": "tool_call", + "toolCallId": "call-grok-1", + "title": "read", + "kind": "other", + "status": "pending", + "rawInput": {"path": "a.txt"}, + } + ), + envelope( + { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-grok-1", + "title": "read", + "status": "completed", + "content": [ + { + "type": "content", + "content": {"type": "text", "text": "GROK_RESULT"}, + } + ], + } + ), + envelope( + { + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": "GROK_ASSISTANT"}, + } + ), + envelope({"sessionUpdate": "available_commands_update", "availableCommands": []}), + ] + (session / "summary.json").write_text(json.dumps(summary)) + (session / "updates.jsonl").write_text("".join(json.dumps(item) + "\n" for item in updates)) + return session + + +def test_grok_source_projects_native_updates(tmp_path: Path) -> None: + source = grok.parse_session(write_native_session(tmp_path)) + + assert source.source_format == AgentFormat.GROK + assert source.session_id == SESSION_ID + assert source.title == "Synthetic Grok session" + assert source.event_counts() == { + "context": 1, + "message": 2, + "opaque": 2, + "tool_call": 1, + "tool_result": 1, + } + call = next(item for item in source.events if item.kind == EventKind.TOOL_CALL) + result = next(item for item in source.events if item.kind == EventKind.TOOL_RESULT) + assert call.payload["input"] == {"path": "a.txt"} + assert result.text == "GROK_RESULT" + + +def test_grok_writer_round_trips_messages_tools_and_image(tmp_path: Path) -> None: + source = grok.parse_session(write_native_session(tmp_path)) + target_id = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + + data, dropped = grok.serialize( + source, + session_id=target_id, + cwd=Path("/tmp/grok-target"), + title="Migrated Grok session", + ) + parsed = grok.validate_native_bytes(data, target_id) + summary, updates = grok.native_files(data, target_id) + + assert parsed.summary["generated_title"] == "Migrated Grok session" + assert json.loads(summary)["info"]["id"] == target_id + assert len(updates.splitlines()) == len(parsed.updates) + assert grok.native_record_count(data) == len(parsed.updates) + 1 + assert dropped == { + "grok_available_commands_update": 1, + "grok_private_thinking": 1, + } + + +def test_grok_writer_counts_private_thinking_and_flattens_compaction(tmp_path: Path) -> None: + source = Session( + source_format=AgentFormat.CODEX, + source_path=tmp_path / "source.jsonl", + source_sha256="0" * 64, + session_id=None, + cwd=tmp_path, + started_at="2026-08-26T12:00:00Z", + cli_version=None, + model=None, + title=None, + events=( + Event(EventKind.MESSAGE, Provenance(0), role=Role.USER, text="hello"), + Event(EventKind.THINKING, Provenance(1), role=Role.ASSISTANT, text="private"), + Event( + EventKind.COMPACTION, + Provenance(2), + role=Role.SYSTEM, + text="summary", + payload={"has_boundary_metadata": True}, + ), + ), + raw_record_count=3, + ) + + data, dropped = grok.serialize(source, session_id=SESSION_ID, cwd=tmp_path) + + grok.validate_native_bytes(data, SESSION_ID) + assert dropped == { + "compaction:boundary_metadata": 1, + "compaction:flattened": 1, + "thinking:private": 1, + } + + +@pytest.mark.parametrize("mutation", ["wrong_session", "bad_method", "missing_content"]) +def test_grok_source_rejects_malformed_updates(tmp_path: Path, mutation: str) -> None: + session = write_native_session(tmp_path) + path = session / "updates.jsonl" + lines = path.read_text().splitlines() + value = json.loads(lines[0]) + if mutation == "wrong_session": + value["params"]["sessionId"] = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + elif mutation == "bad_method": + value["method"] = "session/other" + else: + del value["params"]["update"]["content"] + lines[0] = json.dumps(value) + path.write_text("\n".join(lines) + "\n") + + with pytest.raises((JsonlError, SessionMigrateError)): + grok.parse_session(session) + + +def test_grok_bundle_rejects_duplicate_json_and_wrong_target(tmp_path: Path) -> None: + source = grok.parse_session(write_native_session(tmp_path)) + data, _ = grok.serialize(source, session_id=SESSION_ID, cwd=tmp_path) + duplicate = data.decode().replace( + '"schema":"session-migrate.grok.v1"', + '"schema":"first","schema":"session-migrate.grok.v1"', + 1, + ) + + with pytest.raises(SessionMigrateError, match="valid UTF-8 JSON"): + grok.validate_native_bytes(duplicate.encode(), SESSION_ID) + with pytest.raises(SessionMigrateError, match="linkage"): + grok.validate_native_bytes(data, "dddddddd-dddd-4ddd-8ddd-dddddddddddd") + + +def test_grok_cwd_encoding_matches_short_url_encoded_layout() -> None: + assert grok.session_relative_path(Path("/tmp/a b"), SESSION_ID) == Path( + "sessions/%2Ftmp%2Fa%20b" + ) / SESSION_ID diff --git a/tests/test_kilo_format.py b/tests/test_kilo_format.py new file mode 100644 index 0000000..3af85da --- /dev/null +++ b/tests/test_kilo_format.py @@ -0,0 +1,64 @@ +import hashlib +import json +from dataclasses import replace +from pathlib import Path + +import pytest + +from session_migrate.errors import SessionMigrateError +from session_migrate.formats import kilo, opencode +from session_migrate.model import AgentFormat + +FIXTURE = Path(__file__).parent / "fixtures" / "opencode-source-1.17.20" / "comprehensive.json" + + +def test_kilo_source_projects_official_export_bundle() -> None: + source = kilo.parse_session(FIXTURE) + + assert source.source_format == AgentFormat.KILO + assert source.source_sha256 == hashlib.sha256(FIXTURE.read_bytes()).hexdigest() + assert source.session_id == "ses_33333333333343338333333333333333" + assert source.event_counts()["tool_call"] == 1 + assert source.event_counts()["tool_result"] == 1 + + +def test_kilo_writer_round_trips_portable_history() -> None: + source = kilo.parse_session(FIXTURE) + target_id = kilo.session_id_from_uuid("44444444-4444-4444-8444-444444444444") + + data, dropped = kilo.serialize( + source, + session_id=target_id, + cwd=Path("/tmp/session-migrate-kilo"), + provider_id="fixture", + model_id="fixture-model", + ) + + kilo.validate_native_bytes(data, target_id) + value = json.loads(data) + assert value["info"]["version"] == kilo.PINNED_KILO_VERSION + assert kilo.native_record_count(data) > len(value["messages"]) + assert dropped + + +def test_kilo_validator_rejects_wrong_session_id_and_empty_history() -> None: + source = kilo.parse_session(FIXTURE) + target_id = kilo.session_id_from_uuid("55555555-5555-4555-8555-555555555555") + data, _ = kilo.serialize(source, session_id=target_id, cwd=Path("/tmp/kilo")) + + with pytest.raises(SessionMigrateError, match="does not match"): + wrong_id = kilo.session_id_from_uuid("66666666-6666-4666-8666-666666666666") + kilo.validate_native_bytes(data, wrong_id) + + value = json.loads(data) + value["messages"] = [] + with pytest.raises(SessionMigrateError, match="no resumable"): + kilo.validate_native_bytes(json.dumps(value).encode(), target_id) + + +def test_kilo_source_identity_is_not_an_opencode_alias() -> None: + source = opencode.parse_session(FIXTURE) + kilo_source = replace(source, source_format=AgentFormat.KILO) + + assert source.source_format == AgentFormat.OPENCODE + assert kilo_source.source_format == AgentFormat.KILO diff --git a/tests/test_openhands_format.py b/tests/test_openhands_format.py new file mode 100644 index 0000000..eebb55a --- /dev/null +++ b/tests/test_openhands_format.py @@ -0,0 +1,271 @@ +import json +from pathlib import Path + +import pytest + +from session_migrate.errors import JsonlError, SessionMigrateError +from session_migrate.formats import openhands +from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session + +SESSION_ID = "77777777-7777-4777-8777-777777777777" + + +def event(event_id: str, kind: str, source: str, **values: object) -> dict[str, object]: + return { + "id": event_id, + "timestamp": "2026-08-26T12:00:00.000001", + "source": source, + **values, + "kind": kind, + } + + +def write_native_session(tmp_path: Path) -> Path: + conversation = tmp_path / SESSION_ID.replace("-", "") + events = conversation / "events" + events.mkdir(parents=True) + records = [ + event( + "00000000-0000-4000-8000-000000000001", + "SystemPromptEvent", + "agent", + system_prompt={ + "cache_prompt": False, + "type": "text", + "text": "Synthetic system prompt", + }, + tools=[], + dynamic_context={"cache_prompt": False, "type": "text", "text": ""}, + ), + event( + "00000000-0000-4000-8000-000000000002", + "MessageEvent", + "user", + llm_message={ + "role": "user", + "content": [ + {"cache_prompt": False, "type": "text", "text": "OPENHANDS_USER"}, + { + "type": "image", + "image_urls": ["data:image/png;base64,c3ludGhldGlj"], + }, + ], + "thinking_blocks": [], + }, + activated_skills=[], + extended_content=[], + ), + event( + "00000000-0000-4000-8000-000000000003", + "ActionEvent", + "agent", + thought=[{"cache_prompt": False, "type": "text", "text": "private"}], + thinking_blocks=[], + action={"command": "{}", "kind": "TerminalAction"}, + tool_name="terminal", + tool_call_id="call-openhands-1", + tool_call={ + "id": "call-openhands-1", + "name": "terminal", + "arguments": "{\"command\":\"pwd\"}", + "origin": "completion", + }, + ), + event( + "00000000-0000-4000-8000-000000000004", + "ObservationEvent", + "environment", + tool_name="terminal", + tool_call_id="call-openhands-1", + observation={ + "content": [ + { + "cache_prompt": False, + "type": "text", + "text": "OPENHANDS_RESULT", + } + ], + "is_error": False, + "kind": "TerminalObservation", + }, + action_id="00000000-0000-4000-8000-000000000003", + ), + event( + "00000000-0000-4000-8000-000000000005", + "MessageEvent", + "agent", + llm_message={ + "role": "assistant", + "content": [ + { + "cache_prompt": False, + "type": "text", + "text": "OPENHANDS_ASSISTANT", + } + ], + "thinking_blocks": [{"type": "thinking", "thinking": "private"}], + }, + activated_skills=["synthetic-skill"], + extended_content=[], + ), + event( + "00000000-0000-4000-8000-000000000006", + "Condensation", + "environment", + forgotten_event_ids=[], + summary="OPENHANDS_SUMMARY", + ), + ] + for index, record in enumerate(records): + path = events / f"event-{index:05d}-{record['id']}.json" + path.write_text(json.dumps(record)) + return conversation + + +def test_openhands_source_projects_messages_tools_media_and_compaction(tmp_path: Path) -> None: + session = openhands.parse_session(write_native_session(tmp_path)) + + assert session.source_format == AgentFormat.OPENHANDS + assert session.session_id == SESSION_ID + assert session.raw_record_count == 6 + assert session.event_counts() == { + "compaction": 1, + "context": 1, + "message": 2, + "opaque": 4, + "tool_call": 1, + "tool_result": 1, + } + messages = [item.text for item in session.events if item.kind == EventKind.MESSAGE] + assert messages == ["OPENHANDS_USER", "OPENHANDS_ASSISTANT"] + call = next(item for item in session.events if item.kind == EventKind.TOOL_CALL) + result = next(item for item in session.events if item.kind == EventKind.TOOL_RESULT) + assert call.payload["input"] == {"command": "pwd"} + assert (call.tool_call_id, result.tool_call_id, result.text) == ( + "call-openhands-1", + "call-openhands-1", + "OPENHANDS_RESULT", + ) + + +def test_openhands_writer_round_trips_and_materializes_native_files(tmp_path: Path) -> None: + source = openhands.parse_session(write_native_session(tmp_path)) + target_id = "88888888-8888-4888-8888-888888888888" + + data, dropped = openhands.serialize( + source, + session_id=target_id, + cwd=tmp_path, + title="Synthetic migrated session", + ) + parsed = openhands.validate_native_bytes(data, target_id) + files = openhands.native_files(data, target_id) + + assert parsed.session_id == target_id + assert parsed.title == "Synthetic migrated session" + assert len(files) == openhands.native_record_count(data) + assert files[0][0].startswith("event-00000-") + assert json.loads(files[0][1])["kind"] == "SystemPromptEvent" + assert dropped["openhands_system_prompt"] == 1 + assert dropped["openhands_private_thinking"] == 2 + assert dropped["openhands_message_runtime_metadata"] == 1 + + +def test_openhands_writer_preserves_linked_tools_and_user_images(tmp_path: Path) -> None: + source = Session( + source_format=AgentFormat.CLAUDE, + source_path=tmp_path / "source.jsonl", + source_sha256="0" * 64, + session_id=None, + cwd=tmp_path, + started_at="2026-08-26T12:00:00Z", + cli_version=None, + model=None, + title=None, + events=( + Event(EventKind.MESSAGE, Provenance(0), role=Role.USER, text="hello"), + Event( + EventKind.CONTEXT, + Provenance(0, block_index=1), + role=Role.USER, + payload={ + "block_type": "image", + "image_url": "data:image/png;base64,c3ludGhldGlj", + }, + ), + Event( + EventKind.TOOL_CALL, + Provenance(1), + role=Role.ASSISTANT, + tool_name="read", + tool_call_id="call-1", + payload={"input": {"path": "a.txt"}}, + ), + Event( + EventKind.TOOL_RESULT, + Provenance(2), + role=Role.TOOL, + tool_name="read", + tool_call_id="call-1", + text="result", + ), + ), + raw_record_count=3, + ) + data, dropped = openhands.serialize(source, session_id=SESSION_ID, cwd=tmp_path) + records = openhands.validate_native_bytes(data, SESSION_ID).events + + call = next(item for item in records if item["kind"] == "ActionEvent") + result = next(item for item in records if item["kind"] == "ObservationEvent") + image = next( + item + for item in records + if item["kind"] == "MessageEvent" + and item["llm_message"]["content"][0]["type"] == "image" + ) + assert call["tool_call_id"] == result["tool_call_id"] == "call-1" + assert image["llm_message"]["content"][0]["image_urls"] == [ + "data:image/png;base64,c3ludGhldGlj" + ] + assert dropped == {} + + +@pytest.mark.parametrize("mutation", ["wrong_id", "gap", "bad_role", "unknown_block"]) +def test_openhands_source_rejects_malformed_logs( + tmp_path: Path, mutation: str +) -> None: + conversation = write_native_session(tmp_path) + events = conversation / "events" + if mutation == "gap": + second = sorted(events.glob("event-*.json"))[1] + second.rename(events / second.name.replace("00001", "00009")) + else: + path = sorted(events.glob("event-*.json"))[1] + value = json.loads(path.read_text()) + if mutation == "wrong_id": + value["id"] = "99999999-9999-4999-8999-999999999999" + elif mutation == "bad_role": + value["llm_message"]["role"] = "developer" + else: + value["llm_message"]["content"][0]["type"] = "audio" + path.write_text(json.dumps(value)) + + with pytest.raises((JsonlError, SessionMigrateError)): + openhands.parse_session(conversation) + + +def test_openhands_bundle_rejects_duplicate_members_and_wrong_linkage(tmp_path: Path) -> None: + source = openhands.parse_session(write_native_session(tmp_path)) + data, _ = openhands.serialize(source, session_id=SESSION_ID, cwd=tmp_path) + duplicate = data.decode().replace( + '"schema":"session-migrate.openhands.v1"', + '"schema":"first","schema":"session-migrate.openhands.v1"', + 1, + ) + + with pytest.raises(SessionMigrateError, match="valid UTF-8 JSON"): + openhands.validate_native_bytes(duplicate.encode(), SESSION_ID) + with pytest.raises(SessionMigrateError, match="linkage"): + openhands.validate_native_bytes( + data, "99999999-9999-4999-8999-999999999999" + ) From 4e3abbde45b51371b5dd701328c4a7379fb91163 Mon Sep 17 00:00:00 2001 From: xhluca Date: Wed, 26 Aug 2026 11:24:18 -0400 Subject: [PATCH 02/13] feat: integrate Grok Kilo and OpenHands sessions --- src/session_migrate/catalog.py | 189 ++++++++-- src/session_migrate/cli.py | 111 ++++-- src/session_migrate/conversion.py | 438 +++++++++++++++++++++++- src/session_migrate/discovery.py | 34 +- src/session_migrate/formats/__init__.py | 2 +- src/session_migrate/formats/grok.py | 30 +- src/session_migrate/inspection.py | 52 ++- tests/test_catalog.py | 81 ++++- tests/test_cli.py | 9 + tests/test_discovery.py | 23 ++ tests/test_inspection.py | 31 +- tests/test_route_matrix.py | 73 +++- tests/test_target_integration.py | 128 ++++++- 13 files changed, 1123 insertions(+), 78 deletions(-) diff --git a/src/session_migrate/catalog.py b/src/session_migrate/catalog.py index 834100d..0ccfb73 100644 --- a/src/session_migrate/catalog.py +++ b/src/session_migrate/catalog.py @@ -200,6 +200,11 @@ def auto_roots( if data_home_value else user_home / ".local" / "share" / "opencode" ) + kilo_home = ( + _absolute(Path(data_home_value)) / "kilo" + if data_home_value + else user_home / ".local" / "share" / "kilo" + ) cursor_home = _absolute(cursor_format.config_home(user_home, environ=values)) candidates: list[tuple[AgentFormat, Path, str]] = [ (AgentFormat.CLAUDE, user_home / ".claude", "default"), @@ -211,6 +216,11 @@ def auto_roots( opencode_home, "environment" if data_home_value else "default", ), + ( + AgentFormat.KILO, + kilo_home, + "environment" if data_home_value else "default", + ), (AgentFormat.COPILOT, user_home / ".copilot", "default"), ( AgentFormat.ANTIGRAVITY, @@ -251,6 +261,20 @@ def auto_roots( else user_home / ".kimi-code", "environment" if values.get("KIMI_CODE_HOME") else "default", ), + ( + AgentFormat.GROK, + _absolute(Path(values["GROK_HOME"])) + if values.get("GROK_HOME") + else user_home / ".grok", + "environment" if values.get("GROK_HOME") else "default", + ), + ( + AgentFormat.OPENHANDS, + _absolute(Path(values["OPENHANDS_CONVERSATIONS_DIR"])) + if values.get("OPENHANDS_CONVERSATIONS_DIR") + else user_home / ".openhands" / "conversations", + "environment" if values.get("OPENHANDS_CONVERSATIONS_DIR") else "default", + ), ] configured = ( (AgentFormat.CLAUDE, values.get("CLAUDE_CONFIG_DIR")), @@ -297,6 +321,12 @@ def auto_roots( kimi_home = directory / ".kimi-code" if (kimi_home / "sessions").is_dir(): candidates.append((AgentFormat.KIMI, kimi_home, "project")) + grok_home = directory / ".grok" + if (grok_home / "sessions").is_dir(): + candidates.append((AgentFormat.GROK, grok_home, "project")) + openhands_home = directory / ".openhands" / "conversations" + if openhands_home.is_dir(): + candidates.append((AgentFormat.OPENHANDS, openhands_home, "project")) result: list[tuple[AgentFormat, Path, str]] = [] seen: set[tuple[AgentFormat, str]] = set() @@ -383,6 +413,15 @@ def discover_roots(search_paths: Sequence[Path]) -> list[tuple[AgentFormat, Path candidates.append((AgentFormat.QWEN, current_path)) if current_path.name == ".kimi-code" and (current_path / "sessions").is_dir(): candidates.append((AgentFormat.KIMI, current_path)) + if current_path.name == ".grok" and (current_path / "sessions").is_dir(): + candidates.append((AgentFormat.GROK, current_path)) + if ( + current_path.name == "conversations" + and current_path.parent.name == ".openhands" + ): + candidates.append((AgentFormat.OPENHANDS, current_path)) + if current_path.name == "kilo" and (current_path / "kilo.db").is_file(): + candidates.append((AgentFormat.KILO, current_path)) for agent_format, path in candidates: key = (agent_format, str(path)) if key not in seen: @@ -807,6 +846,9 @@ def add_root( AgentFormat.MUSE, AgentFormat.QWEN, AgentFormat.KIMI, + AgentFormat.GROK, + AgentFormat.KILO, + AgentFormat.OPENHANDS, }: raise SessionMigrateError("catalog root format is unsupported") normalized = str(_absolute(path)) @@ -860,6 +902,9 @@ def refresh( muse_roots: Sequence[Path] = (), qwen_roots: Sequence[Path] = (), kimi_roots: Sequence[Path] = (), + grok_roots: Sequence[Path] = (), + kilo_roots: Sequence[Path] = (), + openhands_roots: Sequence[Path] = (), discover_under: Sequence[Path] = (), include_auto: bool = True, validate: bool = False, @@ -894,6 +939,12 @@ def refresh( self.add_root(AgentFormat.QWEN, path) for path in kimi_roots: self.add_root(AgentFormat.KIMI, path) + for path in grok_roots: + self.add_root(AgentFormat.GROK, path) + for path in kilo_roots: + self.add_root(AgentFormat.KILO, path) + for path in openhands_roots: + self.add_root(AgentFormat.OPENHANDS, path) for agent_format, path, source in discover_roots(discover_under): self.add_root(agent_format, path, source=source) @@ -948,7 +999,7 @@ def refresh( ) def _refresh_root(self, root: CatalogRoot, *, validate: bool) -> dict[str, int]: - if root.format == AgentFormat.OPENCODE.value: + if root.format in {AgentFormat.OPENCODE.value, AgentFormat.KILO.value}: return self._refresh_opencode_root(root) counts = { "files_seen": 0, @@ -1009,6 +1060,14 @@ def _refresh_root(self, root: CatalogRoot, *, validate: bool) -> dict[str, int]: before = _kimi_session_snapshot(path) except JsonlError: continue + elif root.format in { + AgentFormat.GROK.value, + AgentFormat.OPENHANDS.value, + }: + try: + before = _directory_session_snapshot(path, root.format) + except JsonlError: + continue elif root.format in { AgentFormat.ANTIGRAVITY.value, AgentFormat.CURSOR.value, @@ -1131,10 +1190,20 @@ def _refresh_opencode_root(self, root: CatalogRoot) -> dict[str, int]: seen: set[str] = set() now = _utc_now() try: - with _opencode_inventory(root_path) as (database_snapshot, rows): + database_name = ( + "opencode.db" if root.format == AgentFormat.OPENCODE.value else "kilo.db" + ) + with _opencode_inventory(root_path, database_name=database_name) as ( + database_snapshot, + rows, + ): self._connection.execute("BEGIN") for native_row in rows: - scan, snapshot = _scan_opencode_row(native_row, database_snapshot) + scan, snapshot = _scan_opencode_row( + native_row, + database_snapshot, + AgentFormat(root.format), + ) native_id = _string(native_row["id"]) relative_key = ( native_id @@ -1301,7 +1370,7 @@ def _upsert_virtual_session( """Persist an ID-addressed native source without inventing a file path.""" catalog_id = str(previous["catalog_id"]) if previous else uuid.uuid4().hex[:16] - canonical_source = f"opencode:{scan.session_id or relative.removeprefix('session/')}" + canonical_source = f"{root.format}:{scan.session_id or relative.removeprefix('session/')}" self._connection.execute( """ INSERT INTO sessions( @@ -1598,8 +1667,12 @@ def session_source_for_transfer(self, catalog_id: str) -> CatalogTransferSource: ) assert entry.root is not None agent_format = AgentFormat(entry.format) - path = None if agent_format == AgentFormat.OPENCODE else Path(entry.path or "") - if agent_format != AgentFormat.OPENCODE and not entry.path: + path = ( + None + if agent_format in {AgentFormat.OPENCODE, AgentFormat.KILO} + else Path(entry.path or "") + ) + if agent_format not in {AgentFormat.OPENCODE, AgentFormat.KILO} and not entry.path: raise SessionMigrateError("catalog session has no physical source path") return CatalogTransferSource( format=agent_format, @@ -1618,7 +1691,7 @@ def session_path_for_transfer(self, catalog_id: str) -> tuple[AgentFormat, Path] source = self.session_source_for_transfer(catalog_id) if source.path is None: raise SessionMigrateError( - "cataloged OpenCode sources are native IDs, not transcript files" + "cataloged OpenCode/Kilo sources are native IDs, not transcript files" ) return source.format, source.path @@ -1707,6 +1780,20 @@ def _candidate_files(agent_format: AgentFormat, root: Path) -> Iterable[Path]: if path.is_file() and not path.is_symlink() ) return + if agent_format == AgentFormat.GROK: + yield from sorted( + path.parent + for path in (root / "sessions").glob("*/*/summary.json") + if path.is_file() + and not path.is_symlink() + and (path.parent / "updates.jsonl").is_file() + ) + return + if agent_format == AgentFormat.OPENHANDS: + yield from sorted( + path for path in root.glob("*/events") if path.is_dir() and not path.is_symlink() + ) + return if agent_format == AgentFormat.CLAUDE: directories = [root / "projects"] elif agent_format == AgentFormat.CODEX: @@ -1745,7 +1832,13 @@ def _scan_file(path: Path, agent_format: AgentFormat, root: Path) -> _Scan: return _scan_cursor_file(path, root) if agent_format == AgentFormat.VIBE: return _scan_vibe_file(path, root) - if agent_format in {AgentFormat.MUSE, AgentFormat.QWEN, AgentFormat.KIMI}: + if agent_format in { + AgentFormat.MUSE, + AgentFormat.QWEN, + AgentFormat.KIMI, + AgentFormat.GROK, + AgentFormat.OPENHANDS, + }: return _scan_new_portable_file(path, agent_format, root) identity_labels = _native_key_labels(path, agent_format, root) try: @@ -2081,6 +2174,10 @@ def _base_scan( filename_id = _normalized_uuid(path.parent.name) elif agent_format == AgentFormat.KIMI: filename_id = _normalized_uuid(path.parent.parent.parent.name.removeprefix("session_")) + elif agent_format == AgentFormat.GROK: + filename_id = _normalized_uuid(path.name) + elif agent_format == AgentFormat.OPENHANDS: + filename_id = _normalized_uuid(path.parent.name) else: filename_id = _filename_uuid(path) parent_id = None @@ -2106,6 +2203,8 @@ def _base_scan( AgentFormat.MUSE, AgentFormat.QWEN, AgentFormat.KIMI, + AgentFormat.GROK, + AgentFormat.OPENHANDS, }: kind = "main" lifecycle = "active" @@ -2289,6 +2388,48 @@ def _sqlite_session_snapshot(path: Path, format_name: str) -> _VirtualSnapshot: return _VirtualSnapshot(main.st_dev, main.st_ino, total_size, newest, fingerprint) +def _directory_session_snapshot(path: Path, format_name: str) -> _VirtualSnapshot: + """Track all authoritative files of a directory-backed native session.""" + + try: + directory = path.lstat() + except OSError as exc: + raise JsonlError(f"{format_name} session directory is unavailable") from exc + if path.is_symlink() or not path.is_dir(): + raise JsonlError(f"{format_name} session directory is invalid") + if format_name == AgentFormat.GROK.value: + candidates = (path / "summary.json", path / "updates.jsonl") + else: + candidates = tuple(sorted(path.glob("event-*.json"))) + if not candidates: + raise JsonlError(f"{format_name} session has no native records") + components: list[str] = [] + total_size = 0 + newest = directory.st_mtime_ns + for candidate in candidates: + try: + info = candidate.lstat() + except OSError as exc: + raise JsonlError(f"{format_name} session state is unavailable") from exc + if candidate.is_symlink() or not candidate.is_file(): + raise JsonlError(f"{format_name} session state is not a regular file") + total_size += info.st_size + if total_size > DEFAULT_MAX_TOTAL_BYTES: + raise JsonlError(f"{format_name} session exceeds the input safety limit") + newest = max(newest, info.st_mtime_ns) + components.append( + f"{candidate.name}:{info.st_dev}:{info.st_ino}:{info.st_size}:{info.st_mtime_ns}" + ) + fingerprint = sha256("\0".join(components).encode()).hexdigest() + return _VirtualSnapshot( + directory.st_dev, + directory.st_ino, + total_size, + newest, + fingerprint, + ) + + def _vibe_session_snapshot(path: Path) -> _VirtualSnapshot: """Track Vibe's messages and metadata files as one incremental source.""" @@ -2443,18 +2584,21 @@ def _cursor_unavailable_scan(path: Path, root: Path) -> _Scan: @contextmanager def _opencode_inventory( root: Path, + *, + database_name: str = "opencode.db", ) -> Iterator[tuple[_VirtualSnapshot, Iterator[sqlite3.Row]]]: - """Yield a coherent, read-only projection of OpenCode session metadata.""" + """Yield a coherent, read-only OpenCode-lineage session inventory.""" - database = root / "opencode.db" + database = root / database_name + label = "kilo" if database_name == "kilo.db" else "opencode" if database.is_symlink(): - raise _OpenCodeInventoryError("opencode_database_symlink") + raise _OpenCodeInventoryError(f"{label}_database_symlink") try: stat_result = database.stat() except OSError as exc: - raise _OpenCodeInventoryError("opencode_database_unavailable") from exc + raise _OpenCodeInventoryError(f"{label}_database_unavailable") from exc if not database.is_file(): - raise _OpenCodeInventoryError("opencode_database_unavailable") + raise _OpenCodeInventoryError(f"{label}_database_unavailable") base_snapshot = _VirtualSnapshot( stat_result.st_dev, stat_result.st_ino, @@ -2482,7 +2626,7 @@ def _opencode_inventory( "time_updated", } if not required.issubset(columns): - raise _OpenCodeInventoryError("opencode_schema_unsupported") + raise _OpenCodeInventoryError(f"{label}_schema_unsupported") selected = [ "id", f"substr(directory, 1, {PATH_VALUE_LIMIT}) AS directory", @@ -2505,7 +2649,7 @@ def _opencode_inventory( except (OSError, sqlite3.Error) as exc: if connection is not None: connection.close() - raise _OpenCodeInventoryError("opencode_database_unreadable") from exc + raise _OpenCodeInventoryError(f"{label}_database_unreadable") from exc try: yield base_snapshot, iter(rows) finally: @@ -2514,7 +2658,9 @@ def _opencode_inventory( def _scan_opencode_row( - row: sqlite3.Row, database: _VirtualSnapshot + row: sqlite3.Row, + database: _VirtualSnapshot, + agent_format: AgentFormat = AgentFormat.OPENCODE, ) -> tuple[_Scan, _VirtualSnapshot]: raw_id = _string(row["id"]) session_id = raw_id if raw_id and _OPENCODE_SESSION_ID.fullmatch(raw_id) else None @@ -2530,16 +2676,17 @@ def _scan_opencode_row( archived_at = _iso_from_milliseconds(archived) if archived is not None else None status = "candidate" reason = None + prefix = "kilo" if agent_format == AgentFormat.KILO else "opencode" if session_id is None: - status, reason = "corrupt", "invalid_opencode_session_id" + status, reason = "corrupt", f"invalid_{prefix}_session_id" elif not cwd or "\0" in cwd or not title or not cli_version: - status, reason = "corrupt", "invalid_opencode_metadata" + status, reason = "corrupt", f"invalid_{prefix}_metadata" elif started_at is None or updated_at is None or int(updated) < int(created): - status, reason = "corrupt", "invalid_opencode_time" + status, reason = "corrupt", f"invalid_{prefix}_time" elif archived is not None and archived_at is None: - status, reason = "corrupt", "invalid_opencode_archive_time" + status, reason = "corrupt", f"invalid_{prefix}_archive_time" elif parent is not None and not _OPENCODE_SESSION_ID.fullmatch(parent): - status, reason = "corrupt", "invalid_opencode_parent_id" + status, reason = "corrupt", f"invalid_{prefix}_parent_id" labels = (_Label("native_title", title, 0, 110),) if title else () scan = _Scan( session_id=session_id, diff --git a/src/session_migrate/cli.py b/src/session_migrate/cli.py index 58beb55..d201a53 100644 --- a/src/session_migrate/cli.py +++ b/src/session_migrate/cli.py @@ -12,6 +12,7 @@ from session_migrate import __version__ from session_migrate.catalog import Catalog, CatalogEntry, default_catalog_path from session_migrate.conversion import ( + KILO_HOME_UNSUPPORTED, OPENCODE_HOME_UNSUPPORTED, ConversionOptions, content_free_result, @@ -21,9 +22,14 @@ install_antigravity_artifact, install_copilot_artifact, install_cursor_artifact, + install_grok_artifact, + install_kilo_artifact, install_kimi_artifact, install_opencode_artifact, + install_openhands_artifact, install_vibe_artifact, + kilo_manifest_path, + load_kilo_session, load_opencode_session, load_session, opencode_manifest_path, @@ -41,7 +47,8 @@ def build_parser() -> argparse.ArgumentParser: prog="session-migrate", description=( "Migrate Claude, Codex, Pi, Oh My Pi, OpenCode, Copilot, Antigravity, Vibe, " - "experimental Cursor, Muse, Qwen, and Kimi sessions between native formats." + "experimental Cursor, Muse, Qwen, Kimi, Grok, Kilo, and OpenHands sessions " + "between native formats." ), ) parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") @@ -59,7 +66,7 @@ def build_parser() -> argparse.ArgumentParser: inspect_parser.add_argument( "path", type=_expanded_path, - help="source transcript, native session directory, or OpenCode export bundle", + help="source transcript, native session directory, or supported export bundle", ) inspect_parser.add_argument( "--format", choices=tuple(AgentFormat), help="override source detection" @@ -72,7 +79,7 @@ def build_parser() -> argparse.ArgumentParser: convert_parser.add_argument( "path", type=_expanded_path, - help="source transcript, native session directory, or OpenCode export bundle", + help="source transcript, native session directory, or supported export bundle", ) convert_parser.add_argument( "--to", @@ -94,7 +101,7 @@ def build_parser() -> argparse.ArgumentParser: import_parser.add_argument( "path", type=_expanded_path, - help="source transcript, native session directory, or OpenCode export bundle", + help="source transcript, native session directory, or supported export bundle", ) import_parser.add_argument( "--to", @@ -143,7 +150,7 @@ def build_parser() -> argparse.ArgumentParser: transfer_parser.add_argument( "--source-cli", type=_expanded_path, - help="OpenCode source executable used for the official export", + help="OpenCode or Kilo source executable used for the official export", ) transfer_parser.add_argument( "--source-cwd", @@ -253,6 +260,27 @@ def build_parser() -> argparse.ArgumentParser: default=[], help="register and scan an additional Kimi Code home (repeatable)", ) + refresh_parser.add_argument( + "--grok-root", + type=_expanded_path, + action="append", + default=[], + help="register and scan an additional Grok home (repeatable)", + ) + refresh_parser.add_argument( + "--kilo-root", + type=_expanded_path, + action="append", + default=[], + help="register and scan an additional Kilo data home (repeatable)", + ) + refresh_parser.add_argument( + "--openhands-root", + type=_expanded_path, + action="append", + default=[], + help="register and scan an additional OpenHands conversations root (repeatable)", + ) refresh_parser.add_argument( "--discover-under", type=_expanded_path, @@ -333,7 +361,7 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command in {"convert", "import", "transfer"}: if args.command == "transfer": session = None - opencode_source_environ = None + virtual_source_environ = None if args.catalog_id or args.title: if args.source_id: raise SessionMigrateError( @@ -387,9 +415,9 @@ def main(argv: Sequence[str] | None = None) -> int: "--from does not match the catalog session format" ) requested_source_id = entry.session_id - if source_format == AgentFormat.OPENCODE: - opencode_source_environ = dict(os.environ) - opencode_source_environ["XDG_DATA_HOME"] = str(source_reference.root.parent) + if source_format in {AgentFormat.OPENCODE, AgentFormat.KILO}: + virtual_source_environ = dict(os.environ) + virtual_source_environ["XDG_DATA_HOME"] = str(source_reference.root.parent) else: if not args.source_id or not args.source_agent: raise SessionMigrateError( @@ -397,16 +425,19 @@ def main(argv: Sequence[str] | None = None) -> int: ) source_format = AgentFormat(args.source_agent) requested_source_id = normalized_source_id(source_format, args.source_id) - if source_format == AgentFormat.OPENCODE: + if source_format in {AgentFormat.OPENCODE, AgentFormat.KILO}: if args.source_home or args.source_cwd: raise SessionMigrateError( - "OpenCode source transfer uses its normal HOME/XDG environment; " + f"{source_format.value} source transfer uses its normal " + "HOME/XDG environment; " "--source-home/--source-cwd do not apply" ) - session = load_opencode_session( - requested_source_id, - source_cli=args.source_cli, + loader = ( + load_opencode_session + if source_format == AgentFormat.OPENCODE + else load_kilo_session ) + session = loader(requested_source_id, source_cli=args.source_cli) else: source_home = args.source_home or default_target_home(source_format) source_path = locate_session( @@ -415,18 +446,27 @@ def main(argv: Sequence[str] | None = None) -> int: source_home, cwd=args.source_cwd, ) - if args.source_cli and source_format != AgentFormat.OPENCODE: - raise SessionMigrateError("--source-cli applies only to OpenCode transfer") + if args.source_cli and source_format not in { + AgentFormat.OPENCODE, + AgentFormat.KILO, + }: + raise SessionMigrateError("--source-cli applies only to OpenCode/Kilo transfer") if session is None: - if source_format == AgentFormat.OPENCODE: + if source_format in {AgentFormat.OPENCODE, AgentFormat.KILO}: if not requested_source_id: raise SessionMigrateError( - "cataloged OpenCode session is missing its native session ID" + f"cataloged {source_format.value} session is missing its " + "native session ID" ) - session = load_opencode_session( + loader = ( + load_opencode_session + if source_format == AgentFormat.OPENCODE + else load_kilo_session + ) + session = loader( requested_source_id, source_cli=args.source_cli, - environ=opencode_source_environ, + environ=virtual_source_environ, ) else: assert source_path is not None @@ -457,17 +497,21 @@ def main(argv: Sequence[str] | None = None) -> int: target_format = TargetFormat(args.to) if target_format == TargetFormat.OPENCODE and getattr(args, "home", None): raise SessionMigrateError(OPENCODE_HOME_UNSUPPORTED) + if target_format == TargetFormat.KILO and getattr(args, "home", None): + raise SessionMigrateError(KILO_HOME_UNSUPPORTED) if args.target_cli and ( target_format not in { TargetFormat.OPENCODE, + TargetFormat.KILO, TargetFormat.ANTIGRAVITY, TargetFormat.CURSOR, } or args.command == "convert" ): raise SessionMigrateError( - "--target-cli only applies to OpenCode/Antigravity/Cursor import and transfer" + "--target-cli only applies to OpenCode/Kilo/Antigravity/Cursor import " + "and transfer" ) artifact = convert_session( session, @@ -488,6 +532,10 @@ def main(argv: Sequence[str] | None = None) -> int: output_path = f"opencode:{artifact.session_id}" manifest_path = opencode_manifest_path(artifact) dry_run = args.dry_run + elif target_format == TargetFormat.KILO: + output_path = f"kilo:{artifact.session_id}" + manifest_path = kilo_manifest_path(artifact) + dry_run = args.dry_run else: home = args.home or default_target_home(target_format) output_path, manifest_path = target_import_paths(artifact, home) @@ -505,6 +553,13 @@ def main(argv: Sequence[str] | None = None) -> int: target_cli=args.target_cli, dry_run=dry_run, ) + elif target_format == TargetFormat.KILO and args.command != "convert": + install_kilo_artifact( + artifact, + manifest_path=manifest_path, + target_cli=args.target_cli, + dry_run=dry_run, + ) elif target_format == TargetFormat.COPILOT and args.command != "convert": install_copilot_artifact( artifact, @@ -537,6 +592,10 @@ def main(argv: Sequence[str] | None = None) -> int: target_home=home, dry_run=dry_run, ) + elif target_format == TargetFormat.GROK and args.command != "convert": + install_grok_artifact(artifact, target_home=home, dry_run=dry_run) + elif target_format == TargetFormat.OPENHANDS and args.command != "convert": + install_openhands_artifact(artifact, target_home=home, dry_run=dry_run) elif not dry_run: write_artifact( artifact, @@ -594,7 +653,7 @@ def _add_conversion_arguments( "--target-cli", type=_expanded_path, help=( - "OpenCode, Antigravity, or Cursor executable for native import " + "OpenCode, Kilo, Antigravity, or Cursor executable for native import " "(otherwise resolve the pinned CLI from its normal location/PATH)" ), ) @@ -604,7 +663,10 @@ def _add_conversion_arguments( ) parser.add_argument( "--model", - help=("Claude/Pi/OMP/OpenCode/Copilot/Antigravity/Vibe/Muse/Qwen/Kimi target model label"), + help=( + "Claude/Pi/OMP/OpenCode/Kilo/Copilot/Antigravity/Vibe/Muse/Qwen/Kimi/" + "Grok/OpenHands target model label" + ), ) @@ -662,6 +724,9 @@ def _run_catalog(args: argparse.Namespace) -> int: muse_roots=args.muse_root, qwen_roots=args.qwen_root, kimi_roots=args.kimi_root, + grok_roots=args.grok_root, + kilo_roots=args.kilo_root, + openhands_roots=args.openhands_root, discover_under=args.discover_under, include_auto=not args.no_auto_roots, validate=args.validate, diff --git a/src/session_migrate/conversion.py b/src/session_migrate/conversion.py index 5406400..f918b93 100644 --- a/src/session_migrate/conversion.py +++ b/src/session_migrate/conversion.py @@ -25,10 +25,13 @@ codex, copilot, cursor, + grok, + kilo, kimi, muse, omp, opencode, + openhands, pi, qwen, vibe, @@ -46,6 +49,9 @@ "--home is not supported for OpenCode imports; control OpenCode's normal HOME/XDG " "environment instead" ) +KILO_HOME_UNSUPPORTED = ( + "--home is not supported for Kilo imports; control Kilo's normal HOME/XDG environment instead" +) OPENCODE_COMMAND_TIMEOUT_SECONDS = 30 OPENCODE_EXPORT_TIMEOUT_SECONDS = 120 @@ -134,6 +140,10 @@ def load_session(path: Path, source_format: AgentFormat | None = None) -> Sessio if source_format == AgentFormat.KIMI: # Kimi sessions span state.json and the main-agent wire journal. return kimi.parse_session(path) + if source_format == AgentFormat.GROK: + return grok.parse_session(path) + if source_format == AgentFormat.OPENHANDS: + return openhands.parse_session(path) before = file_snapshot(path) if source_format == AgentFormat.CLAUDE: session = claude.parse(path) @@ -145,6 +155,8 @@ def load_session(path: Path, source_format: AgentFormat | None = None) -> Sessio session = omp.parse_session(path) elif source_format == AgentFormat.OPENCODE: session = opencode.parse_session(path) + elif source_format == AgentFormat.KILO: + session = kilo.parse_session(path) elif source_format == AgentFormat.COPILOT: session = copilot.parse_session(path) elif source_format == AgentFormat.MUSE: @@ -191,11 +203,45 @@ def load_opencode_session( return replace(session, source_path=Path(f"opencode:{session_id}")) +def load_kilo_session( + session_id: str, + *, + source_cli: Path | None = None, + environ: Mapping[str, str] | None = None, +) -> Session: + """Export and parse one native Kilo session through its official CLI.""" + + if not session_id.startswith("ses_"): + raise SessionMigrateError("source Kilo session ID is invalid") + values = dict(os.environ if environ is None else environ) + values.setdefault("OPENCODE_DISABLE_AUTOUPDATE", "true") + values.setdefault("OPENCODE_DISABLE_PRUNE", "true") + cli = _resolve_kilo_cli(source_cli, values) + observed_version = _kilo_version(cli, values) + if observed_version != kilo.PINNED_KILO_VERSION: + raise SessionMigrateError( + "Kilo source CLI version mismatch: expected " + f"{kilo.PINNED_KILO_VERSION}, observed {observed_version}" + ) + temporary_root = values.get("TMPDIR") + with tempfile.TemporaryDirectory( + prefix="session-migrate-kilo-source-", dir=temporary_root + ) as directory_name: + directory = Path(directory_name) + os.chmod(directory, 0o700) + export_path = directory / "export.json" + _invoke_kilo_export(cli, session_id, export_path, values) + session = load_session(export_path, AgentFormat.KILO) + if session.session_id != session_id: + raise SessionMigrateError("Kilo export metadata does not match the requested session") + return replace(session, source_path=Path(f"kilo:{session_id}")) + + def convert_session(session: Session, options: ConversionOptions) -> ConversionArtifact: target_format = TargetFormat(options.target_format.value) same_format_rewrite = session.source_format.value == target_format.value portable_id = _validated_uuid(options.session_id) if options.session_id else str(uuid.uuid4()) - if target_format == TargetFormat.OPENCODE: + if target_format in {TargetFormat.OPENCODE, TargetFormat.KILO}: target_id = opencode.session_id_from_uuid(portable_id) elif target_format == TargetFormat.KIMI: target_id = kimi.native_session_id(portable_id) @@ -360,6 +406,40 @@ def convert_session(session: Session, options: ConversionOptions) -> ConversionA timestamp=timestamp, title=session.title, ) + elif target_format == TargetFormat.GROK: + target_version = options.target_cli_version or grok.PINNED_GROK_VERSION + native_bytes, dropped = grok.serialize( + session, + session_id=target_id, + cwd=target_cwd, + cli_version=target_version, + model=options.model, + timestamp=timestamp, + title=session.title, + ) + elif target_format == TargetFormat.OPENHANDS: + target_version = options.target_cli_version or openhands.PINNED_OPENHANDS_VERSION + native_bytes, dropped = openhands.serialize( + session, + session_id=target_id, + cwd=target_cwd, + cli_version=target_version, + model=options.model, + timestamp=timestamp, + title=session.title, + ) + elif target_format == TargetFormat.KILO: + target_version = options.target_cli_version or kilo.PINNED_KILO_VERSION + native_bytes, dropped = kilo.serialize( + session, + session_id=target_id, + cwd=target_cwd, + cli_version=target_version, + provider_id=provider, + model_id=options.model, + timestamp=timestamp, + title=session.title, + ) else: target_version = options.target_cli_version or opencode.PINNED_OPENCODE_VERSION native_bytes, dropped = opencode.serialize( @@ -433,6 +513,9 @@ def convert_session(session: Session, options: ConversionOptions) -> ConversionA AgentFormat.MUSE: muse.PINNED_MUSE_VERSION, AgentFormat.QWEN: qwen.PINNED_QWEN_VERSION, AgentFormat.KIMI: kimi.PINNED_KIMI_VERSION, + AgentFormat.GROK: grok.PINNED_GROK_VERSION, + AgentFormat.KILO: kilo.PINNED_KILO_VERSION, + AgentFormat.OPENHANDS: openhands.PINNED_OPENHANDS_VERSION, }[session.source_format] if session.cli_version != pinned_source: warnings.append( @@ -496,6 +579,10 @@ def target_import_paths(artifact: ConversionArtifact, target_home: Path) -> tupl native_path = target_home / qwen.session_relative_path(artifact.cwd, artifact.session_id) elif artifact.target_format == TargetFormat.KIMI: native_path = target_home / kimi.session_relative_path(artifact.cwd, artifact.session_id) + elif artifact.target_format == TargetFormat.GROK: + native_path = target_home / grok.session_relative_path(artifact.cwd, artifact.session_id) + elif artifact.target_format == TargetFormat.OPENHANDS: + native_path = target_home / openhands.session_relative_path(artifact.session_id) else: raise SessionMigrateError( f"{artifact.target_format.value} does not use filesystem target import paths" @@ -536,6 +623,10 @@ def default_target_home(target_format: TargetFormat | AgentFormat) -> Path: if target_format.value == TargetFormat.KIMI.value: configured = os.environ.get("KIMI_CODE_HOME") return Path(configured).expanduser() if configured else Path.home() / ".kimi-code" + if target_format.value == TargetFormat.GROK.value: + return grok.grok_home() + if target_format.value == TargetFormat.OPENHANDS.value: + return openhands.conversations_home() raise SessionMigrateError(f"{target_format.value} does not expose a filesystem target home") @@ -557,6 +648,13 @@ def opencode_manifest_path(artifact: ConversionArtifact, *, state_home: Path | N return base / "manifests" / "opencode" / f"{artifact.session_id}.json" +def kilo_manifest_path(artifact: ConversionArtifact, *, state_home: Path | None = None) -> Path: + if artifact.target_format != TargetFormat.KILO: + raise SessionMigrateError("Kilo manifest paths require a Kilo artifact") + base = _absolute_no_follow(state_home) if state_home else default_migration_state_home() + return base / "manifests" / "kilo" / f"{artifact.session_id}.json" + + def write_artifact(artifact: ConversionArtifact, *, output_path: Path, manifest_path: Path) -> None: output_path = _absolute_no_follow(output_path) manifest_path = _absolute_no_follow(manifest_path) @@ -930,6 +1028,121 @@ def install_kimi_artifact( return wire_path, manifest_path +def install_grok_artifact( + artifact: ConversionArtifact, + *, + target_home: Path, + dry_run: bool = False, +) -> tuple[Path, Path]: + """Install Grok's summary and ACP update log as one private session directory.""" + + if artifact.target_format != TargetFormat.GROK: + raise SessionMigrateError("Grok installation requires a Grok artifact") + summary_bytes, updates_bytes = grok.native_files(artifact.native_bytes, artifact.session_id) + session_directory, manifest_path = target_import_paths(artifact, target_home) + summary_path = session_directory / "summary.json" + updates_path = session_directory / "updates.jsonl" + ensure_target_paths_available(session_directory, manifest_path) + if dry_run: + return session_directory, manifest_path + + manifest_bytes = ( + json.dumps(artifact.manifest(output_path=session_directory), indent=2, sort_keys=True) + + "\n" + ).encode() + created_directory = False + identities: list[tuple[Path, tuple[int, int]]] = [] + guards: list[int] = [] + try: + _mkdir_private_tree(session_directory.parent) + try: + session_directory.mkdir(mode=0o700) + created_directory = True + except FileExistsError as exc: + raise JsonlError( + f"refusing to overwrite existing Grok session: {session_directory}" + ) from exc + for path, data in ( + (summary_path, summary_bytes), + (updates_path, updates_bytes), + (manifest_path, manifest_bytes), + ): + identity = write_private_atomic(path, data) + identities.append((path, identity)) + guards.append(_open_identity_guard(path, identity)) + if not all(_path_matches_identity(path, identity) for path, identity in identities): + raise JsonlError("Grok artifact changed during installation") + except BaseException: + for path, identity in reversed(identities): + _unlink_if_identity_matches(path, identity) + if created_directory: + with suppress(OSError): + session_directory.rmdir() + raise + finally: + for descriptor in guards: + os.close(descriptor) + return session_directory, manifest_path + + +def install_openhands_artifact( + artifact: ConversionArtifact, + *, + target_home: Path, + dry_run: bool = False, +) -> tuple[Path, Path]: + """Install the canonical OpenHands event log; runtime state is rebuilt on resume.""" + + if artifact.target_format != TargetFormat.OPENHANDS: + raise SessionMigrateError("OpenHands installation requires an OpenHands artifact") + event_files = openhands.native_files(artifact.native_bytes, artifact.session_id) + events_path, manifest_path = target_import_paths(artifact, target_home) + conversation_directory = events_path.parent + ensure_target_paths_available(conversation_directory, manifest_path) + if dry_run: + return events_path, manifest_path + + manifest_bytes = ( + json.dumps(artifact.manifest(output_path=events_path), indent=2, sort_keys=True) + "\n" + ).encode() + created_conversation = False + identities: list[tuple[Path, tuple[int, int]]] = [] + guards: list[int] = [] + try: + _mkdir_private_tree(conversation_directory.parent) + try: + conversation_directory.mkdir(mode=0o700) + created_conversation = True + except FileExistsError as exc: + raise JsonlError( + f"refusing to overwrite existing OpenHands session: {conversation_directory}" + ) from exc + events_path.mkdir(mode=0o700) + for name, data in event_files: + path = events_path / name + identity = write_private_atomic(path, data) + identities.append((path, identity)) + guards.append(_open_identity_guard(path, identity)) + manifest_identity = write_private_atomic(manifest_path, manifest_bytes) + identities.append((manifest_path, manifest_identity)) + guards.append(_open_identity_guard(manifest_path, manifest_identity)) + if not all(_path_matches_identity(path, identity) for path, identity in identities): + raise JsonlError("OpenHands artifact changed during installation") + except BaseException: + for path, identity in reversed(identities): + _unlink_if_identity_matches(path, identity) + if created_conversation: + with suppress(OSError): + events_path.rmdir() + with suppress(OSError): + conversation_directory.rmdir() + raise + finally: + for descriptor in guards: + os.close(descriptor) + return events_path, manifest_path + + def install_opencode_artifact( artifact: ConversionArtifact, *, @@ -1027,6 +1240,98 @@ def install_opencode_artifact( return cli +def install_kilo_artifact( + artifact: ConversionArtifact, + *, + manifest_path: Path, + target_cli: Path | None = None, + dry_run: bool = False, + environ: Mapping[str, str] | None = None, +) -> Path: + """Preflight and import through Kilo's public CLI without touching SQLite.""" + + if artifact.target_format != TargetFormat.KILO: + raise SessionMigrateError("official Kilo import requires a Kilo artifact") + kilo.validate_native_bytes(artifact.native_bytes, artifact.session_id) + values = dict(os.environ if environ is None else environ) + values.setdefault("OPENCODE_DISABLE_AUTOUPDATE", "true") + values.setdefault("OPENCODE_DISABLE_PRUNE", "true") + if artifact.target_cli_version != kilo.PINNED_KILO_VERSION: + raise SessionMigrateError( + "automatic Kilo import requires target metadata version " + f"{kilo.PINNED_KILO_VERSION}; convert-only artifacts may opt into " + "unvalidated metadata versions" + ) + cli = _resolve_kilo_cli(target_cli, values) + observed_version = _kilo_version(cli, values) + if observed_version != kilo.PINNED_KILO_VERSION: + raise SessionMigrateError( + "Kilo CLI version mismatch: expected " + f"{kilo.PINNED_KILO_VERSION}, observed {observed_version}" + ) + if artifact.session_id in _kilo_session_ids(cli, values): + raise SessionMigrateError( + "Kilo session ID already exists; refusing to overwrite native session: " + f"{artifact.session_id}" + ) + + manifest_path = _absolute_no_follow(manifest_path) + ensure_target_paths_available(manifest_path) + if dry_run: + return cli + + target_location = f"kilo:{artifact.session_id}" + manifest_bytes = ( + json.dumps(artifact.manifest(output_path=target_location), indent=2, sort_keys=True) + "\n" + ).encode() + reservation_identity: tuple[int, int] | None = None + reservation_guard: int | None = None + import_succeeded = False + try: + reservation_identity = write_private_atomic(manifest_path, b"") + reservation_guard = _open_identity_guard(manifest_path, reservation_identity, writable=True) + if artifact.session_id in _kilo_session_ids(cli, values): + raise SessionMigrateError( + "Kilo session ID appeared during import preflight; refusing to continue: " + f"{artifact.session_id}" + ) + + temporary_root = values.get("TMPDIR") + with tempfile.TemporaryDirectory( + prefix="session-migrate-kilo-", dir=temporary_root + ) as directory_name: + directory = Path(directory_name) + os.chmod(directory, 0o700) + bundle_path = directory / "import.json" + write_private_atomic(bundle_path, artifact.native_bytes) + _invoke_kilo_import(cli, bundle_path, values) + import_succeeded = True + + if artifact.session_id not in _kilo_session_ids(cli, values): + raise SessionMigrateError( + "Kilo import returned success but the session was not discoverable afterward" + ) + _write_reserved_file( + reservation_guard, + manifest_path, + reservation_identity, + manifest_bytes, + ) + except BaseException as exc: + if reservation_identity is not None: + _unlink_if_identity_matches(manifest_path, reservation_identity) + if import_succeeded: + raise SessionMigrateError( + "Kilo import succeeded but migrator manifest finalization failed; " + f"the native session may already exist as {artifact.session_id}" + ) from exc + raise + finally: + if reservation_guard is not None: + os.close(reservation_guard) + return cli + + def ensure_target_paths_available(*paths: Path) -> None: """Fail if a planned conversion would collide, including during dry-run.""" @@ -1068,6 +1373,15 @@ def _validated_uuid(value: str) -> str: def _validate_native_bytes(data: bytes, target_format: TargetFormat, session_id: str) -> None: + if target_format == TargetFormat.GROK: + grok.validate_native_bytes(data, session_id) + return + if target_format == TargetFormat.KILO: + kilo.validate_native_bytes(data, session_id) + return + if target_format == TargetFormat.OPENHANDS: + openhands.validate_native_bytes(data, session_id) + return if target_format == TargetFormat.MUSE: muse.validate_native_bytes(data, session_id) return @@ -1139,10 +1453,19 @@ def _pinned_target_version(target_format: TargetFormat) -> str: TargetFormat.MUSE: muse.PINNED_MUSE_VERSION, TargetFormat.QWEN: qwen.PINNED_QWEN_VERSION, TargetFormat.KIMI: kimi.PINNED_KIMI_VERSION, + TargetFormat.GROK: grok.PINNED_GROK_VERSION, + TargetFormat.KILO: kilo.PINNED_KILO_VERSION, + TargetFormat.OPENHANDS: openhands.PINNED_OPENHANDS_VERSION, }[target_format] def _native_record_count(data: bytes, target_format: TargetFormat) -> int: + if target_format == TargetFormat.GROK: + return grok.native_record_count(data) + if target_format == TargetFormat.KILO: + return kilo.native_record_count(data) + if target_format == TargetFormat.OPENHANDS: + return openhands.native_record_count(data) if target_format == TargetFormat.OMP: return omp.native_record_count(data) if target_format == TargetFormat.KIMI: @@ -1308,6 +1631,119 @@ def _run_opencode( return completed +def _resolve_kilo_cli(target_cli: Path | None, environ: Mapping[str, str]) -> Path: + candidates: list[Path] = [] + if target_cli is not None: + candidates.append(_absolute_no_follow(target_cli)) + elif environ.get("KILO_BIN"): + candidates.append(_absolute_no_follow(Path(environ["KILO_BIN"]))) + else: + discovered = shutil.which("kilo", path=environ.get("PATH")) + if discovered: + candidates.append(_absolute_no_follow(Path(discovered))) + for candidate in candidates: + if candidate.is_file() and os.access(candidate, os.X_OK): + return candidate + raise SessionMigrateError( + "Kilo CLI was not found; pass --target-cli, set KILO_BIN, or add kilo to PATH" + ) + + +def _kilo_version(cli: Path, environ: Mapping[str, str]) -> str: + completed = _run_kilo([str(cli), "--version"], environ) + version = completed.stdout.strip() + if not version or "\n" in version: + raise SessionMigrateError("Kilo CLI returned an invalid version string") + return version + + +def _kilo_session_ids(cli: Path, environ: Mapping[str, str]) -> set[str]: + completed = _run_kilo( + [str(cli), "session", "list", "--all", "--format", "json", "--pure"], environ + ) + if len(completed.stdout.encode()) > 64 * 1024 * 1024: + raise SessionMigrateError("Kilo session list exceeded the safety limit") + if not completed.stdout.strip(): + return set() + try: + value = json.loads(completed.stdout, parse_constant=_reject_json_constant) + except (json.JSONDecodeError, ValueError) as exc: + raise SessionMigrateError("Kilo session list did not return valid JSON") from exc + if not isinstance(value, list): + raise SessionMigrateError("Kilo session list returned an unexpected JSON shape") + result: set[str] = set() + for item in value: + if not isinstance(item, dict) or not isinstance(item.get("id"), str): + raise SessionMigrateError("Kilo session list contains invalid metadata") + result.add(item["id"]) + return result + + +def _invoke_kilo_import(cli: Path, bundle_path: Path, environ: Mapping[str, str]) -> None: + _run_kilo([str(cli), "import", str(bundle_path), "--pure"], environ) + + +def _invoke_kilo_export( + cli: Path, + session_id: str, + bundle_path: Path, + environ: Mapping[str, str], +) -> None: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor: int | None = None + try: + descriptor = os.open(bundle_path, flags, 0o600) + completed = subprocess.run( + [str(cli), "export", session_id, "--pure"], + env=dict(environ), + check=False, + stdout=descriptor, + stderr=subprocess.PIPE, + timeout=OPENCODE_EXPORT_TIMEOUT_SECONDS, + ) + os.fsync(descriptor) + except (OSError, subprocess.TimeoutExpired) as exc: + with suppress(OSError): + bundle_path.unlink() + raise SessionMigrateError("Kilo CLI export failed") from exc + finally: + if descriptor is not None: + os.close(descriptor) + if completed.returncode != 0: + with suppress(OSError): + bundle_path.unlink() + raise SessionMigrateError(f"Kilo CLI export failed with exit status {completed.returncode}") + try: + exported_size = bundle_path.stat().st_size + except OSError as exc: + raise SessionMigrateError("Kilo export artifact is unavailable") from exc + if exported_size == 0 or exported_size > kilo.MAX_NATIVE_BYTES: + with suppress(OSError): + bundle_path.unlink() + raise SessionMigrateError("Kilo export artifact is empty or exceeds the safety limit") + + +def _run_kilo(command: list[str], environ: Mapping[str, str]) -> subprocess.CompletedProcess[str]: + try: + completed = subprocess.run( + command, + env=dict(environ), + check=False, + capture_output=True, + text=True, + timeout=OPENCODE_COMMAND_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise SessionMigrateError("Kilo CLI invocation failed") from exc + if completed.returncode != 0: + raise SessionMigrateError( + f"Kilo CLI command failed with exit status {completed.returncode}" + ) + return completed + + def _write_reserved_file( descriptor: int, path: Path, diff --git a/src/session_migrate/discovery.py b/src/session_migrate/discovery.py index 5b490f5..e0eb46e 100644 --- a/src/session_migrate/discovery.py +++ b/src/session_migrate/discovery.py @@ -9,7 +9,7 @@ from pathlib import Path from session_migrate.errors import SessionMigrateError -from session_migrate.formats import claude, cursor, kimi, omp, pi, qwen, vibe +from session_migrate.formats import claude, cursor, grok, kimi, omp, pi, qwen, vibe from session_migrate.model import AgentFormat _OPENCODE_SESSION_ID = re.compile(r"ses_[0-9A-Za-z]{1,128}") @@ -64,11 +64,26 @@ def locate_session( matches = _qwen_matches(home, normalized_id, cwd) elif source_format == AgentFormat.KIMI: matches = _kimi_matches(home, normalized_id, cwd) + elif source_format == AgentFormat.GROK: + matches = _grok_matches(home, normalized_id, cwd) + elif source_format == AgentFormat.OPENHANDS: + if cwd is not None: + raise SessionMigrateError("--source-cwd does not apply to OpenHands discovery") + matches = [home / normalized_id.replace("-", "") / "events"] else: raise SessionMigrateError( - "OpenCode sessions are exported through its official CLI, not located as files" + "OpenCode and Kilo sessions are exported through their official CLIs, " + "not located as files" ) - matches = sorted({path for path in matches if path.is_file()}) + matches = sorted( + { + path + for path in matches + if path.is_file() + or source_format in {AgentFormat.GROK, AgentFormat.OPENHANDS} + and path.is_dir() + } + ) if not matches: raise SessionMigrateError( f"no {source_format.value} session found for UUID in the selected source home" @@ -83,6 +98,7 @@ def locate_session( AgentFormat.OMP, AgentFormat.CURSOR, AgentFormat.VIBE, + AgentFormat.GROK, } | {AgentFormat.QWEN, AgentFormat.KIMI} else "remove duplicates" @@ -175,6 +191,13 @@ def _kimi_matches(home: Path, session_id: str, cwd: Path | None) -> list[Path]: return list(home.glob(f"sessions/*/{native_id}/agents/main/{kimi.WIRE_FILENAME}")) +def _grok_matches(home: Path, session_id: str, cwd: Path | None) -> list[Path]: + sessions = home / "sessions" + if cwd is not None: + return [sessions / grok.encode_cwd(cwd) / session_id] + return [path.parent for path in sessions.glob(f"*/{session_id}/summary.json")] + + def normalized_session_id(value: str) -> str: try: return str(uuid.UUID(value)) @@ -185,9 +208,10 @@ def normalized_session_id(value: str) -> str: def normalized_source_id(source_format: AgentFormat, value: str) -> str: """Normalize a native source ID without pretending every agent uses UUIDs.""" - if source_format == AgentFormat.OPENCODE: + if source_format in {AgentFormat.OPENCODE, AgentFormat.KILO}: if not _OPENCODE_SESSION_ID.fullmatch(value): - raise SessionMigrateError("source OpenCode session ID is invalid") + label = "OpenCode" if source_format == AgentFormat.OPENCODE else "Kilo" + raise SessionMigrateError(f"source {label} session ID is invalid") return value if source_format == AgentFormat.KIMI: return kimi.native_session_id(value) diff --git a/src/session_migrate/formats/__init__.py b/src/session_migrate/formats/__init__.py index 8efcab7..a88e310 100644 --- a/src/session_migrate/formats/__init__.py +++ b/src/session_migrate/formats/__init__.py @@ -11,8 +11,8 @@ kimi, muse, omp, - openhands, opencode, + openhands, pi, qwen, vibe, diff --git a/src/session_migrate/formats/grok.py b/src/session_migrate/formats/grok.py index 633a7f3..435cd6f 100644 --- a/src/session_migrate/formats/grok.py +++ b/src/session_migrate/formats/grok.py @@ -20,9 +20,7 @@ PINNED_GROK_VERSION = "1.0.5" PINNED_GROK_LINUX_X64_BYTES = 166_854_368 -PINNED_GROK_LINUX_X64_SHA256 = ( - "9ba87444e1819e8f6104adbbf4676a870c204380aa5c3e1c38a926c4ea677238" -) +PINNED_GROK_LINUX_X64_SHA256 = "9ba87444e1819e8f6104adbbf4676a870c204380aa5c3e1c38a926c4ea677238" GROK_BUNDLE_SCHEMA = "session-migrate.grok.v1" MAX_BUNDLE_BYTES = DEFAULT_MAX_TOTAL_BYTES MAX_UPDATES = DEFAULT_MAX_RECORDS @@ -287,9 +285,7 @@ def validate_native_bytes(data: bytes, session_id: str) -> ParsedGrokBundle: raise SessionMigrateError("generated Grok bundle session linkage is invalid") if not string(info.get("cwd")) or not valid_rfc3339(summary.get("created_at")): raise SessionMigrateError("generated Grok summary metadata is invalid") - if not isinstance(summary.get("num_messages"), int) or summary["num_messages"] != len( - updates - ): + if not isinstance(summary.get("num_messages"), int) or summary["num_messages"] != len(updates): raise SessionMigrateError("generated Grok summary count is inconsistent") if not updates or len(updates) > MAX_UPDATES: raise SessionMigrateError("generated Grok bundle has no resumable updates") @@ -342,6 +338,19 @@ def _parse_update(record: dict[str, Any], index: int) -> list[Event]: role = Role.USER if kind.startswith("user") else Role.ASSISTANT content = update.get("content") if content.get("type") == "text": + if role == Role.USER and content["text"].startswith( + "[Imported conversation summary]\n" + ): + return [ + Event( + EventKind.COMPACTION, + provenance, + role=Role.SYSTEM, + text=content["text"].removeprefix("[Imported conversation summary]\n"), + timestamp=timestamp, + payload={"source_subtype": "grok_imported_summary"}, + ) + ] return [ Event( EventKind.MESSAGE, @@ -400,7 +409,10 @@ def _parse_update(record: dict[str, Any], index: int) -> list[Event]: tool_name=string(update.get("title")), tool_call_id=string(update.get("toolCallId")), text=text, - payload={"is_error": update.get("status") == "failed"}, + payload={ + "is_error": update.get("status") == "failed", + "content_blocks": [{"type": "text", "text": text}], + }, ) ] return [ @@ -458,9 +470,7 @@ def _validate_update(update: dict[str, Any]) -> None: and not (string(content.get("data")) and string(content.get("mimeType"))) ): raise JsonlError("Grok image update is malformed") - elif kind in {"tool_call", "tool_call_update"} and not string( - update.get("toolCallId") - ): + elif kind in {"tool_call", "tool_call_update"} and not string(update.get("toolCallId")): raise JsonlError("Grok tool update is malformed") diff --git a/src/session_migrate/inspection.py b/src/session_migrate/inspection.py index 067ada1..dab1a3e 100644 --- a/src/session_migrate/inspection.py +++ b/src/session_migrate/inspection.py @@ -10,7 +10,16 @@ from typing import Any from session_migrate.errors import FormatDetectionError, JsonlError, SessionMigrateError -from session_migrate.formats import antigravity, cursor, kimi, muse, qwen, vibe +from session_migrate.formats import ( + antigravity, + cursor, + grok, + kimi, + muse, + openhands, + qwen, + vibe, +) from session_migrate.jsonl import ( DEFAULT_MAX_TOTAL_BYTES, ensure_file_unchanged, @@ -95,6 +104,19 @@ def to_json(self) -> str: def inspect_session(path: Path, *, source_format: AgentFormat | None = None) -> Inspection: + if source_format == AgentFormat.GROK or ( + source_format is None + and path.is_dir() + and (path / "summary.json").is_file() + and (path / "updates.jsonl").is_file() + ): + return _inspect_portable_database(grok.parse_session(path)) + if source_format == AgentFormat.OPENHANDS or ( + source_format is None + and path.is_dir() + and (path.name == "events" or (path / "events").is_dir()) + ): + return _inspect_portable_database(openhands.parse_session(path)) if source_format == AgentFormat.ANTIGRAVITY: parsed = antigravity.parse_session(path) return _inspect_portable_database(parsed) @@ -132,12 +154,18 @@ def inspect_session(path: Path, *, source_format: AgentFormat | None = None) -> ) from exc return _inspect_portable_database(parsed) before = file_snapshot(path) - if source_format == AgentFormat.OPENCODE or source_format is None: + if source_format in {AgentFormat.OPENCODE, AgentFormat.KILO} or source_format is None: document = _load_json_document(path, before.size) if document is not None and ( - source_format == AgentFormat.OPENCODE or _is_opencode_document(document) + source_format in {AgentFormat.OPENCODE, AgentFormat.KILO} + or _is_opencode_document(document) ): - result = _inspect_opencode(path, before.size, document) + result = _inspect_opencode( + path, + before.size, + document, + source_format or AgentFormat.OPENCODE, + ) ensure_file_unchanged(path, before) return result records = list(iter_jsonl(path)) @@ -441,6 +469,16 @@ def detect_path_format(path: Path) -> AgentFormat: """Detect JSON-document and JSONL source formats under the normal input bounds.""" if path.is_dir(): + if (path / "summary.json").is_file() and (path / "updates.jsonl").is_file(): + grok.parse_session(path) + return AgentFormat.GROK + if path.name == "events" or (path / "events").is_dir(): + try: + openhands.parse_session(path) + except SessionMigrateError: + pass + else: + return AgentFormat.OPENHANDS if (path / kimi.STATE_FILENAME).is_file() and ( path / "agents/main" / kimi.WIRE_FILENAME ).is_file(): @@ -495,7 +533,9 @@ def _is_opencode_document(value: dict[str, Any]) -> bool: ) -def _inspect_opencode(path: Path, size: int, value: dict[str, Any]) -> Inspection: +def _inspect_opencode( + path: Path, size: int, value: dict[str, Any], source_format: AgentFormat +) -> Inspection: info = value.get("info") messages = value.get("messages") assert isinstance(info, dict) and isinstance(messages, list) @@ -540,7 +580,7 @@ def _inspect_opencode(path: Path, size: int, value: dict[str, Any]) -> Inspectio except (OverflowError, OSError, ValueError): started_at = None return Inspection( - format=AgentFormat.OPENCODE.value, + format=source_format.value, path=str(path.resolve()), bytes=size, sha256=file_sha256(path), diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 55a6223..7a9ca6c 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -9,7 +9,15 @@ import session_migrate.catalog as catalog_module from session_migrate.catalog import Catalog, auto_roots, default_catalog_path, discover_roots from session_migrate.errors import JsonlError, SessionMigrateError -from session_migrate.formats import antigravity, claude, cursor, omp, vibe +from session_migrate.formats import ( + antigravity, + claude, + cursor, + grok, + omp, + openhands, + vibe, +) from session_migrate.model import AgentFormat CLAUDE_ID = "11111111-1111-4111-8111-111111111111" @@ -29,6 +37,9 @@ ANTIGRAVITY_ID = "99999999-9999-4999-8999-999999999999" CURSOR_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" VIBE_ID = "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" +GROK_ID = "16161616-1616-4616-8616-161616161616" +OPENHANDS_ID = "17171717-1717-4717-8717-171717171717" +KILO_ID = "ses_17171717171747178717171717171717" def _write_jsonl(path: Path, records: list[dict[str, object]]) -> None: @@ -146,9 +157,9 @@ def _catalog(tmp_path: Path) -> Catalog: return Catalog(tmp_path / "private-state" / "catalog.sqlite3") -def _opencode_database(home: Path) -> sqlite3.Connection: +def _opencode_database(home: Path, database_name: str = "opencode.db") -> sqlite3.Connection: home.mkdir(parents=True, exist_ok=True) - connection = sqlite3.connect(home / "opencode.db") + connection = sqlite3.connect(home / database_name) connection.executescript( """ CREATE TABLE session ( @@ -560,6 +571,70 @@ def test_opencode_inventory_failures_retain_rows_and_reject_database_symlink( assert len(catalog.list_sessions(query=OPENCODE_ID)) == 1 +def test_grok_kilo_and_openhands_catalog_roots_are_complete_searchable_and_transferable( + tmp_path: Path, +) -> None: + source = claude.parse(Path(__file__).parent / "fixtures/claude-2.1.209/basic.jsonl") + grok_home = tmp_path / "grok-home" + grok_bytes, _ = grok.serialize( + source, + session_id=GROK_ID, + cwd=tmp_path, + timestamp="2026-08-25T12:00:00Z", + title="Repair timeline merging", + ) + grok_directory = grok_home / grok.session_relative_path(tmp_path, GROK_ID) + grok_directory.mkdir(parents=True) + summary, updates = grok.native_files(grok_bytes, GROK_ID) + (grok_directory / "summary.json").write_bytes(summary) + (grok_directory / "updates.jsonl").write_bytes(updates) + + openhands_home = tmp_path / "openhands-conversations" + openhands_bytes, _ = openhands.serialize( + source, + session_id=OPENHANDS_ID, + cwd=tmp_path, + timestamp="2026-08-25T12:00:00Z", + ) + openhands_events = openhands_home / openhands.session_relative_path(OPENHANDS_ID) + openhands_events.mkdir(parents=True) + for name, data in openhands.native_files(openhands_bytes, OPENHANDS_ID): + (openhands_events / name).write_bytes(data) + + kilo_home = tmp_path / "kilo-data" + connection = _opencode_database(kilo_home, "kilo.db") + _insert_opencode_session(connection, KILO_ID, "Implement catalog keyword search") + connection.execute("UPDATE session SET version = '7.5.0' WHERE id = ?", (KILO_ID,)) + connection.commit() + connection.close() + + with _catalog(tmp_path) as catalog: + first = catalog.refresh( + grok_roots=(grok_home,), + kilo_roots=(kilo_home,), + openhands_roots=(openhands_home,), + include_auto=False, + ) + assert first.files_seen == 3 + assert first.root_errors == 0 + assert len(catalog.list_sessions(query="timeline merging")) == 1 + assert len(catalog.list_sessions(query="catalog keyword")) == 1 + entries = catalog.list_sessions(limit=10) + assert {entry.format for entry in entries} == {"grok", "kilo", "openhands"} + + by_format = {entry.format: entry for entry in entries} + grok_source = catalog.session_source_for_transfer(by_format["grok"].catalog_id) + kilo_source = catalog.session_source_for_transfer(by_format["kilo"].catalog_id) + openhands_source = catalog.session_source_for_transfer(by_format["openhands"].catalog_id) + assert grok_source.path == grok_directory + assert kilo_source.is_virtual and kilo_source.session_id == KILO_ID + assert openhands_source.path == openhands_events + + second = catalog.refresh(include_auto=False) + assert second.unchanged == 3 + assert second.scanned == 0 + + def test_copilot_inventory_includes_valid_corrupt_missing_and_symlinked_logs( tmp_path: Path, ) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ca0fc4..a84f6c4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -94,6 +94,12 @@ def test_parser_expands_home_in_every_path_argument( "~/copilot", "--antigravity-root", "~/antigravity", + "--grok-root", + "~/grok", + "--kilo-root", + "~/kilo", + "--openhands-root", + "~/openhands", "--discover-under", "~/workspace", ] @@ -108,6 +114,9 @@ def test_parser_expands_home_in_every_path_argument( assert refresh_args.opencode_root == [tmp_path / "opencode-data"] assert refresh_args.copilot_root == [tmp_path / "copilot"] assert refresh_args.antigravity_root == [tmp_path / "antigravity"] + assert refresh_args.grok_root == [tmp_path / "grok"] + assert refresh_args.kilo_root == [tmp_path / "kilo"] + assert refresh_args.openhands_root == [tmp_path / "openhands"] assert refresh_args.discover_under == [tmp_path / "workspace"] diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 351f547..bb5e1eb 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -6,6 +6,7 @@ from session_migrate.errors import SessionMigrateError from session_migrate.formats.claude import project_directory_name from session_migrate.formats.cursor import workspace_key +from session_migrate.formats.grok import encode_cwd from session_migrate.formats.omp import session_directory_name as omp_session_directory_name from session_migrate.formats.pi import session_directory_name from session_migrate.model import AgentFormat @@ -138,3 +139,25 @@ def test_normalizes_native_opencode_id_and_requires_official_export(tmp_path: Pa locate_session(AgentFormat.OPENCODE, native_id, tmp_path) with pytest.raises(SessionMigrateError, match="OpenCode session ID is invalid"): normalized_source_id(AgentFormat.OPENCODE, "../not-an-id") + + +def test_locates_grok_and_openhands_directory_sessions_and_normalizes_kilo( + tmp_path: Path, +) -> None: + cwd = tmp_path / "project" + grok_home = tmp_path / "grok" + grok_path = grok_home / "sessions" / encode_cwd(cwd) / SESSION_ID + grok_path.mkdir(parents=True) + (grok_path / "summary.json").write_text("{}") + assert locate_session(AgentFormat.GROK, SESSION_ID, grok_home, cwd=cwd) == grok_path + assert locate_session(AgentFormat.GROK, SESSION_ID, grok_home) == grok_path + + openhands_home = tmp_path / "openhands" + events = openhands_home / SESSION_ID.replace("-", "") / "events" + events.mkdir(parents=True) + assert locate_session(AgentFormat.OPENHANDS, SESSION_ID, openhands_home) == events + + native_id = "ses_295e9e462ffeKSKb526cRKYtpw" + assert normalized_source_id(AgentFormat.KILO, native_id) == native_id + with pytest.raises(SessionMigrateError, match="Kilo session ID is invalid"): + normalized_source_id(AgentFormat.KILO, "../not-an-id") diff --git a/tests/test_inspection.py b/tests/test_inspection.py index 4913a7c..b375007 100644 --- a/tests/test_inspection.py +++ b/tests/test_inspection.py @@ -5,7 +5,7 @@ from session_migrate import inspection from session_migrate.errors import FormatDetectionError, JsonlError -from session_migrate.formats import antigravity, claude, cursor, omp +from session_migrate.formats import antigravity, claude, cursor, grok, kilo, omp, openhands from session_migrate.inspection import inspect_session from session_migrate.model import AgentFormat @@ -375,3 +375,32 @@ def hash_then_append(source_path: Path) -> str: with pytest.raises(JsonlError, match="source session changed"): inspect_session(path) + + +def test_inspects_grok_kilo_and_openhands_without_exposing_bodies(tmp_path: Path) -> None: + source = claude.parse(Path(__file__).parent / "fixtures/claude-2.1.209/basic.jsonl") + session_id = "18181818-1818-4818-8818-181818181818" + + grok_bytes, _ = grok.serialize(source, session_id=session_id, cwd=tmp_path) + grok_path = tmp_path / "grok" + grok_path.mkdir() + summary, updates = grok.native_files(grok_bytes, session_id) + (grok_path / "summary.json").write_bytes(summary) + (grok_path / "updates.jsonl").write_bytes(updates) + assert inspection.detect_path_format(grok_path) == AgentFormat.GROK + assert inspect_session(grok_path).format == "grok" + + kilo_id = "ses_18181818181848188818181818181818" + kilo_bytes, _ = kilo.serialize(source, session_id=kilo_id, cwd=tmp_path) + kilo_path = tmp_path / "kilo.json" + kilo_path.write_bytes(kilo_bytes) + kilo_result = inspect_session(kilo_path, source_format=AgentFormat.KILO) + assert kilo_result.format == "kilo" and kilo_result.session_id == kilo_id + + openhands_bytes, _ = openhands.serialize(source, session_id=session_id, cwd=tmp_path) + events = tmp_path / session_id.replace("-", "") / "events" + events.mkdir(parents=True) + for name, data in openhands.native_files(openhands_bytes, session_id): + (events / name).write_bytes(data) + assert inspection.detect_path_format(events) == AgentFormat.OPENHANDS + assert inspect_session(events).format == "openhands" diff --git a/tests/test_route_matrix.py b/tests/test_route_matrix.py index 457028f..c49788c 100644 --- a/tests/test_route_matrix.py +++ b/tests/test_route_matrix.py @@ -11,10 +11,13 @@ codex, copilot, cursor, + grok, + kilo, kimi, muse, omp, opencode, + openhands, pi, qwen, vibe, @@ -116,6 +119,41 @@ def source_sessions(tmp_path: Path) -> dict[str, Session]: (kimi_path / kimi.STATE_FILENAME).write_bytes(state_bytes) (kimi_path / "agents/main" / kimi.WIRE_FILENAME).write_bytes(wire_bytes) sessions["kimi"] = kimi.parse_session(kimi_path) + grok_id = "13131313-1313-4313-8313-131313131313" + grok_bytes, _ = grok.serialize( + sessions["claude"], + session_id=grok_id, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + ) + grok_path = tmp_path / "grok-source" + grok_path.mkdir() + grok_summary, grok_updates = grok.native_files(grok_bytes, grok_id) + (grok_path / "summary.json").write_bytes(grok_summary) + (grok_path / "updates.jsonl").write_bytes(grok_updates) + sessions["grok"] = grok.parse_session(grok_path) + kilo_id = "ses_14141414141444148414141414141414" + kilo_bytes, _ = kilo.serialize( + sessions["claude"], + session_id=kilo_id, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + ) + kilo_path = tmp_path / "kilo-source.json" + kilo_path.write_bytes(kilo_bytes) + sessions["kilo"] = kilo.parse_session(kilo_path) + openhands_id = "15151515-1515-4515-8515-151515151515" + openhands_bytes, _ = openhands.serialize( + sessions["claude"], + session_id=openhands_id, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + ) + openhands_path = tmp_path / openhands_id.replace("-", "") / "events" + openhands_path.mkdir(parents=True) + for name, data in openhands.native_files(openhands_bytes, openhands_id): + (openhands_path / name).write_bytes(data) + sessions["openhands"] = openhands.parse_session(openhands_path) return sessions @@ -179,6 +217,8 @@ def parse_target(path: Path, target: TargetFormat) -> Session: return omp.parse_session(path) if target == TargetFormat.OPENCODE: return opencode.parse_session(path) + if target == TargetFormat.KILO: + return kilo.parse_session(path) if target == TargetFormat.COPILOT: return copilot.parse_session(path) if target == TargetFormat.CURSOR: @@ -191,6 +231,10 @@ def parse_target(path: Path, target: TargetFormat) -> Session: return qwen.parse_session(path) if target == TargetFormat.KIMI: return kimi.parse_session(path) + if target == TargetFormat.GROK: + return grok.parse_session(path) + if target == TargetFormat.OPENHANDS: + return openhands.parse_session(path) return antigravity.parse_session(path) @@ -209,6 +253,9 @@ def parse_target(path: Path, target: TargetFormat) -> Session: "muse", "qwen", "kimi", + "grok", + "kilo", + "openhands", ), ) @pytest.mark.parametrize( @@ -226,6 +273,9 @@ def parse_target(path: Path, target: TargetFormat) -> Session: TargetFormat.MUSE, TargetFormat.QWEN, TargetFormat.KIMI, + TargetFormat.GROK, + TargetFormat.KILO, + TargetFormat.OPENHANDS, ), ) def test_every_supported_source_to_target_route_preserves_portable_timeline( @@ -243,8 +293,8 @@ def test_every_supported_source_to_target_route_preserves_portable_timeline( if target == TargetFormat.COPILOT: output = tmp_path / artifact.session_id / "events.jsonl" output.parent.mkdir() - elif target == TargetFormat.OPENCODE: - output = tmp_path / ("target.json" if target == TargetFormat.OPENCODE else "target.jsonl") + elif target in {TargetFormat.OPENCODE, TargetFormat.KILO}: + output = tmp_path / "target.json" elif target == TargetFormat.ANTIGRAVITY: output = tmp_path / f"{artifact.session_id}.db" elif target == TargetFormat.CURSOR: @@ -264,9 +314,25 @@ def test_every_supported_source_to_target_route_preserves_portable_timeline( (output / "agents/main").mkdir(parents=True) (output / kimi.STATE_FILENAME).write_bytes(state_bytes) (output / "agents/main" / kimi.WIRE_FILENAME).write_bytes(wire_bytes) + elif target == TargetFormat.GROK: + output = tmp_path / "grok-target" + output.mkdir() + summary, updates = grok.native_files(artifact.native_bytes, artifact.session_id) + (output / "summary.json").write_bytes(summary) + (output / "updates.jsonl").write_bytes(updates) + elif target == TargetFormat.OPENHANDS: + output = tmp_path / artifact.session_id.replace("-", "") / "events" + output.mkdir(parents=True) + for name, data in openhands.native_files(artifact.native_bytes, artifact.session_id): + (output / name).write_bytes(data) else: output = tmp_path / "target.jsonl" - if target not in {TargetFormat.VIBE, TargetFormat.KIMI}: + if target not in { + TargetFormat.VIBE, + TargetFormat.KIMI, + TargetFormat.GROK, + TargetFormat.OPENHANDS, + }: output.write_bytes(artifact.native_bytes) reparsed = parse_target(output, target) @@ -284,6 +350,7 @@ def test_every_supported_source_to_target_route_preserves_portable_timeline( TargetFormat.ANTIGRAVITY, TargetFormat.CURSOR, TargetFormat.MUSE, + TargetFormat.GROK, } include_tools = target != TargetFormat.CURSOR group_messages = target in {TargetFormat.COPILOT, TargetFormat.VIBE} diff --git a/tests/test_target_integration.py b/tests/test_target_integration.py index 2529298..a359d6c 100644 --- a/tests/test_target_integration.py +++ b/tests/test_target_integration.py @@ -16,8 +16,12 @@ install_antigravity_artifact, install_copilot_artifact, install_cursor_artifact, + install_grok_artifact, + install_kilo_artifact, install_opencode_artifact, + install_openhands_artifact, install_vibe_artifact, + kilo_manifest_path, opencode_manifest_path, target_import_paths, ) @@ -28,8 +32,11 @@ codex, copilot, cursor, + grok, + kilo, omp, opencode, + openhands, pi, vibe, ) @@ -130,6 +137,9 @@ def test_source_and_target_enums_are_deliberately_separate() -> None: AgentFormat.MUSE, AgentFormat.QWEN, AgentFormat.KIMI, + AgentFormat.GROK, + AgentFormat.KILO, + AgentFormat.OPENHANDS, ) assert set(TargetFormat) == { TargetFormat.CLAUDE, @@ -144,6 +154,9 @@ def test_source_and_target_enums_are_deliberately_separate() -> None: TargetFormat.MUSE, TargetFormat.QWEN, TargetFormat.KIMI, + TargetFormat.GROK, + TargetFormat.KILO, + TargetFormat.OPENHANDS, } @@ -210,6 +223,9 @@ def test_cli_parser_accepts_every_target_and_expands_target_cli( TargetFormat.ANTIGRAVITY, TargetFormat.CURSOR, TargetFormat.VIBE, + TargetFormat.GROK, + TargetFormat.KILO, + TargetFormat.OPENHANDS, ], ) def test_shared_conversion_dispatches_additional_targets( @@ -225,7 +241,7 @@ def test_shared_conversion_dispatches_additional_targets( ) path = tmp_path / ( "target.json" - if target == TargetFormat.OPENCODE + if target in {TargetFormat.OPENCODE, TargetFormat.KILO} else f"{TARGET_UUID}.db" if target in {TargetFormat.ANTIGRAVITY, TargetFormat.CURSOR} else "target.jsonl" @@ -239,9 +255,14 @@ def test_shared_conversion_dispatches_additional_targets( elif target == TargetFormat.OMP: omp.validate_native_bytes(artifact.native_bytes, TARGET_UUID) assert omp.parse(path).session_id == TARGET_UUID - elif target == TargetFormat.OPENCODE: - opencode.validate_native_bytes(artifact.native_bytes, TARGET_OPENCODE_ID) - assert opencode.parse(path).session_id == TARGET_OPENCODE_ID + elif target in {TargetFormat.OPENCODE, TargetFormat.KILO}: + adapter = opencode if target == TargetFormat.OPENCODE else kilo + adapter.validate_native_bytes(artifact.native_bytes, TARGET_OPENCODE_ID) + assert adapter.parse(path).session_id == TARGET_OPENCODE_ID + elif target == TargetFormat.GROK: + grok.validate_native_bytes(artifact.native_bytes, TARGET_UUID) + elif target == TargetFormat.OPENHANDS: + openhands.validate_native_bytes(artifact.native_bytes, TARGET_UUID) elif target == TargetFormat.COPILOT: copilot.validate_native_bytes(artifact.native_bytes, TARGET_UUID) assert copilot.parse(path).session_id == TARGET_UUID @@ -1050,3 +1071,102 @@ def test_opencode_validator_rejects_metadata_only_bundle() -> None: with pytest.raises(SessionMigrateError, match="no resumable conversation context"): opencode.validate_native_bytes(data, TARGET_OPENCODE_ID) + + +def test_load_kilo_session_uses_official_export_and_virtual_source_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source_id = "ses_33333333333343338333333333333333" + source = source_session() + bundle, _ = kilo.serialize( + source, + session_id=source_id, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + ) + cli = tmp_path / "kilo" + monkeypatch.setattr(conversion, "_resolve_kilo_cli", lambda path, env: cli) + monkeypatch.setattr( + conversion, + "_kilo_version", + lambda path, env: kilo.PINNED_KILO_VERSION, + ) + + def export(_cli: Path, session_id: str, output: Path, env: dict[str, str]) -> None: + assert session_id == source_id + output.write_bytes(bundle) + + monkeypatch.setattr(conversion, "_invoke_kilo_export", export) + + parsed = conversion.load_kilo_session(source_id, source_cli=cli, environ={}) + + assert parsed.source_format == AgentFormat.KILO + assert parsed.session_id == source_id + assert str(parsed.source_path) == f"kilo:{source_id}" + + +def test_kilo_official_import_reserves_manifest_and_checks_native_result( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + artifact = convert_session( + source_session(), + ConversionOptions( + target_format=TargetFormat.KILO, + session_id=TARGET_UUID, + cwd=tmp_path, + ), + ) + cli = tmp_path / "kilo" + states = iter((set(), set(), {TARGET_OPENCODE_ID})) + monkeypatch.setattr(conversion, "_resolve_kilo_cli", lambda path, env: cli) + monkeypatch.setattr( + conversion, + "_kilo_version", + lambda path, env: kilo.PINNED_KILO_VERSION, + ) + monkeypatch.setattr(conversion, "_kilo_session_ids", lambda path, env: next(states)) + observed: dict[str, object] = {} + + def invoke(path: Path, bundle_path: Path, env: dict[str, str]) -> None: + observed["bytes"] = bundle_path.read_bytes() + observed["mode"] = bundle_path.stat().st_mode & 0o777 + + monkeypatch.setattr(conversion, "_invoke_kilo_import", invoke) + manifest = kilo_manifest_path(artifact, state_home=tmp_path / "state") + + installed = install_kilo_artifact(artifact, manifest_path=manifest, environ={}) + + assert installed == cli + assert observed == {"bytes": artifact.native_bytes, "mode": 0o600} + assert json.loads(manifest.read_text())["target"]["path"] == (f"kilo:{TARGET_OPENCODE_ID}") + assert manifest.stat().st_mode & 0o777 == 0o600 + + +@pytest.mark.parametrize("target", [TargetFormat.GROK, TargetFormat.OPENHANDS]) +def test_directory_targets_install_private_native_files_and_fail_on_collision( + tmp_path: Path, target: TargetFormat +) -> None: + artifact = convert_session( + source_session(), + ConversionOptions(target_format=target, session_id=TARGET_UUID, cwd=tmp_path), + ) + home = tmp_path / f"{target.value}-home" + installer = install_grok_artifact if target == TargetFormat.GROK else install_openhands_artifact + + native, manifest = installer(artifact, target_home=home, dry_run=True) + assert not home.exists() + installed_native, installed_manifest = installer(artifact, target_home=home) + assert (installed_native, installed_manifest) == (native, manifest) + assert installed_manifest.stat().st_mode & 0o777 == 0o600 + if target == TargetFormat.GROK: + parsed = grok.parse_session(installed_native) + native_files = (installed_native / "summary.json", installed_native / "updates.jsonl") + else: + parsed = openhands.parse_session(installed_native) + native_files = tuple(installed_native.glob("event-*.json")) + assert parsed.session_id == TARGET_UUID + assert native_files + assert all(path.stat().st_mode & 0o777 == 0o600 for path in native_files) + assert installed_native.stat().st_mode & 0o777 == 0o700 + with pytest.raises(SessionMigrateError, match="overwrite"): + installer(artifact, target_home=home, dry_run=True) From 3b8ea1f3dac284c5f7713e3d7ce9fc96484b5196 Mon Sep 17 00:00:00 2001 From: xhluca Date: Wed, 26 Aug 2026 11:51:27 -0400 Subject: [PATCH 03/13] test: verify Grok Kilo and OpenHands native replay --- src/session_migrate/conversion.py | 75 ++-- src/session_migrate/formats/grok.py | 5 +- src/session_migrate/formats/openhands.py | 29 +- tests/test_grok_format.py | 27 +- tests/test_grok_kilo_openhands_native.py | 462 +++++++++++++++++++++++ tests/test_openhands_format.py | 25 +- tests/test_target_integration.py | 50 ++- 7 files changed, 621 insertions(+), 52 deletions(-) create mode 100644 tests/test_grok_kilo_openhands_native.py diff --git a/src/session_migrate/conversion.py b/src/session_migrate/conversion.py index f918b93..3d4d689 100644 --- a/src/session_migrate/conversion.py +++ b/src/session_migrate/conversion.py @@ -1269,7 +1269,7 @@ def install_kilo_artifact( "Kilo CLI version mismatch: expected " f"{kilo.PINNED_KILO_VERSION}, observed {observed_version}" ) - if artifact.session_id in _kilo_session_ids(cli, values): + if _kilo_session_exists(cli, artifact.session_id, values): raise SessionMigrateError( "Kilo session ID already exists; refusing to overwrite native session: " f"{artifact.session_id}" @@ -1290,7 +1290,7 @@ def install_kilo_artifact( try: reservation_identity = write_private_atomic(manifest_path, b"") reservation_guard = _open_identity_guard(manifest_path, reservation_identity, writable=True) - if artifact.session_id in _kilo_session_ids(cli, values): + if _kilo_session_exists(cli, artifact.session_id, values): raise SessionMigrateError( "Kilo session ID appeared during import preflight; refusing to continue: " f"{artifact.session_id}" @@ -1304,10 +1304,10 @@ def install_kilo_artifact( os.chmod(directory, 0o700) bundle_path = directory / "import.json" write_private_atomic(bundle_path, artifact.native_bytes) - _invoke_kilo_import(cli, bundle_path, values) + _invoke_kilo_import(cli, bundle_path, artifact.cwd, values) import_succeeded = True - if artifact.session_id not in _kilo_session_ids(cli, values): + if not _kilo_session_exists(cli, artifact.session_id, values): raise SessionMigrateError( "Kilo import returned success but the session was not discoverable afterward" ) @@ -1657,30 +1657,47 @@ def _kilo_version(cli: Path, environ: Mapping[str, str]) -> str: return version -def _kilo_session_ids(cli: Path, environ: Mapping[str, str]) -> set[str]: - completed = _run_kilo( - [str(cli), "session", "list", "--all", "--format", "json", "--pure"], environ - ) - if len(completed.stdout.encode()) > 64 * 1024 * 1024: - raise SessionMigrateError("Kilo session list exceeded the safety limit") - if not completed.stdout.strip(): - return set() +def _kilo_session_exists(cli: Path, session_id: str, environ: Mapping[str, str]) -> bool: + """Probe one Kilo session without trusting its broken 7.5.0 list command. + + The pinned CLI can import and export sessions correctly, but its JSON list + command raises while formatting imported rows. Export is the supported + per-session API and gives an unambiguous not-found diagnostic. Discarding + stdout also avoids materializing transcript bodies during collision checks. + """ + try: - value = json.loads(completed.stdout, parse_constant=_reject_json_constant) - except (json.JSONDecodeError, ValueError) as exc: - raise SessionMigrateError("Kilo session list did not return valid JSON") from exc - if not isinstance(value, list): - raise SessionMigrateError("Kilo session list returned an unexpected JSON shape") - result: set[str] = set() - for item in value: - if not isinstance(item, dict) or not isinstance(item.get("id"), str): - raise SessionMigrateError("Kilo session list contains invalid metadata") - result.add(item["id"]) - return result + completed = subprocess.run( + [str(cli), "export", session_id, "--pure"], + env=dict(environ), + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + timeout=OPENCODE_COMMAND_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise SessionMigrateError("Kilo CLI session probe failed") from exc + if completed.returncode == 0: + return True + if completed.returncode == 1 and f"Session not found: {session_id}" in completed.stderr: + return False + raise SessionMigrateError( + f"Kilo CLI session probe failed with exit status {completed.returncode}" + ) -def _invoke_kilo_import(cli: Path, bundle_path: Path, environ: Mapping[str, str]) -> None: - _run_kilo([str(cli), "import", str(bundle_path), "--pure"], environ) +def _invoke_kilo_import( + cli: Path, + bundle_path: Path, + cwd: Path, + environ: Mapping[str, str], +) -> None: + # Kilo 7.5.0 intentionally replaces the bundle's directory with the + # importer's current instance directory. Run the official importer from + # the requested target cwd so the resumed session is attached to the right + # workspace rather than session-migrate's own process directory. + _run_kilo([str(cli), "import", str(bundle_path), "--pure"], environ, cwd=cwd) def _invoke_kilo_export( @@ -1725,7 +1742,12 @@ def _invoke_kilo_export( raise SessionMigrateError("Kilo export artifact is empty or exceeds the safety limit") -def _run_kilo(command: list[str], environ: Mapping[str, str]) -> subprocess.CompletedProcess[str]: +def _run_kilo( + command: list[str], + environ: Mapping[str, str], + *, + cwd: Path | None = None, +) -> subprocess.CompletedProcess[str]: try: completed = subprocess.run( command, @@ -1734,6 +1756,7 @@ def _run_kilo(command: list[str], environ: Mapping[str, str]) -> subprocess.Comp capture_output=True, text=True, timeout=OPENCODE_COMMAND_TIMEOUT_SECONDS, + cwd=cwd, ) except (OSError, subprocess.TimeoutExpired) as exc: raise SessionMigrateError("Kilo CLI invocation failed") from exc diff --git a/src/session_migrate/formats/grok.py b/src/session_migrate/formats/grok.py index 435cd6f..7569499 100644 --- a/src/session_migrate/formats/grok.py +++ b/src/session_migrate/formats/grok.py @@ -438,7 +438,10 @@ def _decode_updates(data: bytes, session_id: str) -> list[dict[str, Any]]: value = json.loads(line, object_pairs_hook=_unique_object) except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: raise JsonlError(f"Grok update line {line_number} is not valid JSON") from exc - if not isinstance(value, dict) or value.get("method") != "session/update": + if not isinstance(value, dict) or value.get("method") not in { + "session/update", + "_x.ai/session/update", + }: raise JsonlError("Grok update envelope is malformed") params = value.get("params") update = params.get("update") if isinstance(params, dict) else None diff --git a/src/session_migrate/formats/openhands.py b/src/session_migrate/formats/openhands.py index 137e2ee..1ca09df 100644 --- a/src/session_migrate/formats/openhands.py +++ b/src/session_migrate/formats/openhands.py @@ -171,11 +171,15 @@ def next_record(kind: str, source: str, **fields: Any) -> dict[str, Any]: tool_call={ "id": call_id, "name": name, - "arguments": json.dumps( - arguments, ensure_ascii=False, separators=(",", ":") - ), + "arguments": json.dumps(arguments, ensure_ascii=False, separators=(",", ":")), "origin": "completion", }, + # OpenHands groups parallel tool calls by the response that + # produced them. Portable history does not expose that native + # response ID, so give each imported call a stable-in-file + # synthetic group ID. The field is required by SDK 1.21.0 and + # omitting it makes a resumed conversation fail validation. + llm_response_id=str(uuid.uuid4()), security_risk="LOW", summary=f"Imported {name} call", ) @@ -218,6 +222,7 @@ def next_record(kind: str, source: str, **fields: Any) -> dict[str, Any]: "environment", forgotten_event_ids=[], summary=event.text, + llm_response_id=str(uuid.uuid4()), ) if event.payload.get("has_boundary_metadata") is True: dropped["compaction:boundary_metadata"] += 1 @@ -531,6 +536,7 @@ def _validate_event(value: dict[str, Any], index: int) -> None: elif kind == "ActionEvent": if not string(value.get("tool_name")) or not string(value.get("tool_call_id")): raise SessionMigrateError("OpenHands action event is malformed") + _uuid(value.get("llm_response_id"), "OpenHands action response id") if not isinstance(value.get("action"), dict) or not isinstance( value.get("tool_call"), dict ): @@ -542,11 +548,12 @@ def _validate_event(value: dict[str, Any], index: int) -> None: if not isinstance(observation, dict) or not isinstance(observation.get("is_error"), bool): raise SessionMigrateError("OpenHands observation event is malformed") _validate_content(observation.get("content")) - elif kind == "Condensation" and ( - not string(value.get("summary")) - or not isinstance(value.get("forgotten_event_ids"), list) - ): - raise SessionMigrateError("OpenHands condensation event is malformed") + elif kind == "Condensation": + if not string(value.get("summary")) or not isinstance( + value.get("forgotten_event_ids"), list + ): + raise SessionMigrateError("OpenHands condensation event is malformed") + _uuid(value.get("llm_response_id"), "OpenHands condensation response id") def _validate_content(content: Any) -> None: @@ -561,8 +568,10 @@ def _validate_content(content: Any) -> None: raise SessionMigrateError("OpenHands text block is malformed") elif block_type == "image": urls = block.get("image_urls") - if not isinstance(urls, list) or not urls or not all( - portable_data_image(item) is not None for item in urls + if ( + not isinstance(urls, list) + or not urls + or not all(portable_data_image(item) is not None for item in urls) ): raise SessionMigrateError("OpenHands image block is malformed") else: diff --git a/tests/test_grok_format.py b/tests/test_grok_format.py index 6bde5ec..982d8e8 100644 --- a/tests/test_grok_format.py +++ b/tests/test_grok_format.py @@ -135,6 +135,26 @@ def test_grok_writer_round_trips_messages_tools_and_image(tmp_path: Path) -> Non } +def test_grok_source_accepts_native_xai_turn_completion(tmp_path: Path) -> None: + session = write_native_session(tmp_path) + path = session / "updates.jsonl" + terminal = envelope( + { + "sessionUpdate": "turn_completed", + "prompt_id": "synthetic-prompt", + "stop_reason": "end_turn", + "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2}, + } + ) + terminal["method"] = "_x.ai/session/update" + path.write_text(path.read_text() + json.dumps(terminal) + "\n") + + source = grok.parse_session(session) + + assert source.events[-1].kind == EventKind.OPAQUE + assert source.events[-1].payload == {"reason": "grok_turn_completed"} + + def test_grok_writer_counts_private_thinking_and_flattens_compaction(tmp_path: Path) -> None: source = Session( source_format=AgentFormat.CODEX, @@ -205,6 +225,7 @@ def test_grok_bundle_rejects_duplicate_json_and_wrong_target(tmp_path: Path) -> def test_grok_cwd_encoding_matches_short_url_encoded_layout() -> None: - assert grok.session_relative_path(Path("/tmp/a b"), SESSION_ID) == Path( - "sessions/%2Ftmp%2Fa%20b" - ) / SESSION_ID + assert ( + grok.session_relative_path(Path("/tmp/a b"), SESSION_ID) + == Path("sessions/%2Ftmp%2Fa%20b") / SESSION_ID + ) diff --git a/tests/test_grok_kilo_openhands_native.py b/tests/test_grok_kilo_openhands_native.py new file mode 100644 index 0000000..225069e --- /dev/null +++ b/tests/test_grok_kilo_openhands_native.py @@ -0,0 +1,462 @@ +"""Credential-free native gates for Grok, Kilo Code, and OpenHands. + +The default suite exercises the adapters without installing vendor binaries. +Set the corresponding ``SESSION_MIGRATE_*_BIN`` variable to run an exact, +version-and-digest-pinned native import/resume trajectory against a local +OpenAI-compatible server. No provider credential or network model is used. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import threading +from collections.abc import Iterator +from contextlib import contextmanager, suppress +from hashlib import file_digest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest +from test_additional_formats import TARGET_UUID, portable_session + +from session_migrate.conversion import ( + ConversionOptions, + convert_session, + install_grok_artifact, + install_kilo_artifact, + install_openhands_artifact, + kilo_manifest_path, +) +from session_migrate.formats import grok, kilo, openhands +from session_migrate.model import EventKind, TargetFormat + + +class LoopbackHandler(BaseHTTPRequestHandler): + requests: list[dict[str, Any]] = [] + + def do_GET(self) -> None: # noqa: N802 + if not self.path.endswith("/models"): + self.send_error(404) + return + self._send_json( + { + "object": "list", + "data": [ + { + "id": "fixture-model", + "object": "model", + "created": 0, + "owned_by": "session-migrate-test", + } + ], + } + ) + + def do_POST(self) -> None: # noqa: N802 + length = int(self.headers.get("content-length", "0")) + value = json.loads(self.rfile.read(length)) + type(self).requests.append(value) + if value.get("stream"): + chunks = [ + { + "id": "chatcmpl-session-migrate-test", + "object": "chat.completion.chunk", + "created": 0, + "model": "fixture-model", + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "content": "SYNTHETIC_NATIVE_REPLY", + }, + "finish_reason": None, + } + ], + }, + { + "id": "chatcmpl-session-migrate-test", + "object": "chat.completion.chunk", + "created": 0, + "model": "fixture-model", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 2, + "total_tokens": 12, + }, + }, + ] + body = "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + encoded = (body + "data: [DONE]\n\n").encode() + self.send_response(200) + self.send_header("content-type", "text/event-stream") + self.send_header("content-length", str(len(encoded))) + self.end_headers() + with suppress(BrokenPipeError): + self.wfile.write(encoded) + return + self._send_json( + { + "id": "chatcmpl-session-migrate-test", + "object": "chat.completion", + "created": 0, + "model": "fixture-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "SYNTHETIC_NATIVE_REPLY", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 2, + "total_tokens": 12, + }, + } + ) + + def _send_json(self, value: object) -> None: + encoded = json.dumps(value).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, format: str, *args: object) -> None: + del format, args + + +@contextmanager +def loopback_server() -> Iterator[tuple[int, type[LoopbackHandler]]]: + handler = LoopbackHandler + handler.requests = [] + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_address[1], handler + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def exact_binary( + variable: str, + *, + expected_bytes: int, + expected_sha256: str, + version_command: list[str], + expected_version: str, +) -> Path: + value = os.environ.get(variable) + if not value: + pytest.skip(f"set {variable} to the exact pinned vendor binary") + path = Path(value).resolve() + assert path.is_file() and not path.is_symlink() + assert path.stat().st_size == expected_bytes + with path.open("rb") as stream: + assert file_digest(stream, "sha256").hexdigest() == expected_sha256 + completed = subprocess.run( + [str(path), *version_command], + check=False, + capture_output=True, + text=True, + timeout=15, + env={ + "HOME": str(path.parent), + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "OPENHANDS_SUPPRESS_BANNER": "1", + }, + ) + assert completed.returncode == 0, completed.stderr + assert expected_version in completed.stdout.strip() + return path + + +def isolated_env(tmp_path: Path) -> dict[str, str]: + home = tmp_path / "home" + values = { + "HOME": str(home), + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "TERM": "dumb", + "NO_COLOR": "1", + "LANG": "C.UTF-8", + "TMPDIR": str(tmp_path / "tmp"), + "XDG_DATA_HOME": str(home / "data"), + "XDG_CONFIG_HOME": str(home / "config"), + "XDG_CACHE_HOME": str(home / "cache"), + "XDG_STATE_HOME": str(home / "state"), + "OPENCODE_DISABLE_AUTOUPDATE": "true", + "OPENCODE_DISABLE_PRUNE": "true", + } + for value in values.values(): + if value.startswith(str(tmp_path)): + Path(value).mkdir(parents=True, exist_ok=True) + return values + + +def assert_request_markers(requests: list[dict[str, Any]], *markers: str) -> None: + assert requests + replay = json.dumps(requests, sort_keys=True) + for marker in markers: + assert marker in replay + + +def test_grok_105_loads_prefix_and_appends_through_loopback(tmp_path: Path) -> None: + binary = exact_binary( + "SESSION_MIGRATE_GROK_BIN", + expected_bytes=grok.PINNED_GROK_LINUX_X64_BYTES, + expected_sha256=grok.PINNED_GROK_LINUX_X64_SHA256, + version_command=["--version"], + expected_version=f"grok {grok.PINNED_GROK_VERSION}", + ) + work = tmp_path / "work" + work.mkdir() + artifact = convert_session( + portable_session(work, compaction=True), + ConversionOptions( + target_format=TargetFormat.GROK, + session_id=TARGET_UUID, + cwd=work, + model="fixture-model", + ), + ) + grok_home = tmp_path / "grok" + session_path, _ = install_grok_artifact(artifact, target_home=grok_home) + updates = session_path / "updates.jsonl" + before = updates.read_bytes() + + with loopback_server() as (port, handler): + (grok_home / "config.toml").write_text( + "\n".join( + [ + "[models]", + 'default = "fixture-model"', + "[model.fixture-model]", + 'model = "fixture-model"', + f'base_url = "http://127.0.0.1:{port}/v1"', + 'api_key = "synthetic-not-a-secret"', + "context_window = 65536", + "", + ] + ) + ) + completed = subprocess.run( + [ + str(binary), + "--resume", + artifact.session_id, + "--cwd", + str(work), + "--model", + "fixture-model", + "-p", + "SYNTHETIC_GROK_FOLLOWUP", + "--max-turns", + "1", + "--output-format", + "plain", + ], + cwd=work, + env={**isolated_env(tmp_path), "GROK_HOME": str(grok_home)}, + check=False, + capture_output=True, + text=True, + timeout=45, + ) + + assert completed.returncode == 0, (completed.stdout, completed.stderr) + assert "SYNTHETIC_NATIVE_REPLY" in completed.stdout + assert updates.read_bytes().startswith(before) + assert_request_markers( + handler.requests, + "SYNTHETIC_COMPACTION_MARKER", + "SYNTHETIC_FINAL_MARKER", + "SYNTHETIC_GROK_FOLLOWUP", + ) + resumed = grok.parse_session(session_path) + assert any( + event.kind == EventKind.MESSAGE and event.text == "SYNTHETIC_NATIVE_REPLY" + for event in resumed.events + ) + + +def test_kilo_750_official_import_replay_and_export(tmp_path: Path) -> None: + binary = exact_binary( + "SESSION_MIGRATE_KILO_BIN", + expected_bytes=kilo.PINNED_KILO_LINUX_X64_BYTES, + expected_sha256=kilo.PINNED_KILO_LINUX_X64_SHA256, + version_command=["--version"], + expected_version=kilo.PINNED_KILO_VERSION, + ) + work = tmp_path / "work" + work.mkdir() + env = isolated_env(tmp_path) + artifact = convert_session( + portable_session(work, compaction=True), + ConversionOptions( + target_format=TargetFormat.KILO, + session_id=TARGET_UUID, + cwd=work, + model="fixture-model", + model_provider="fixture", + ), + ) + manifest = kilo_manifest_path(artifact, state_home=Path(env["XDG_STATE_HOME"])) + install_kilo_artifact( + artifact, + manifest_path=manifest, + target_cli=binary, + environ=env, + ) + + with loopback_server() as (port, handler): + config = { + "model": "fixture/fixture-model", + "provider": { + "fixture": { + "npm": "@ai-sdk/openai-compatible", + "name": "Synthetic loopback", + "options": { + "baseURL": f"http://127.0.0.1:{port}/v1", + "apiKey": "synthetic-not-a-secret", + }, + "models": { + "fixture-model": { + "name": "Synthetic fixture model", + "attachment": True, + "modalities": { + "input": ["text", "image"], + "output": ["text"], + }, + } + }, + } + }, + } + completed = subprocess.run( + [ + str(binary), + "run", + "SYNTHETIC_KILO_FOLLOWUP", + "--session", + artifact.session_id, + "--model", + "fixture/fixture-model", + "--format", + "json", + "--pure", + ], + cwd=work, + env={**env, "KILO_CONFIG_CONTENT": json.dumps(config)}, + check=False, + capture_output=True, + text=True, + timeout=45, + ) + + assert completed.returncode == 0, (completed.stdout, completed.stderr) + assert "SYNTHETIC_NATIVE_REPLY" in completed.stdout + assert_request_markers( + handler.requests, + "SYNTHETIC_COMPACTION_MARKER", + "SYNTHETIC_FINAL_MARKER", + "SYNTHETIC_KILO_FOLLOWUP", + ) + exported = subprocess.run( + [str(binary), "export", artifact.session_id, "--pure"], + cwd=work, + env=env, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + assert exported.returncode == 0, exported.stderr + export_path = tmp_path / "kilo-export.json" + export_path.write_text(exported.stdout) + replay = kilo.parse_session(export_path) + assert replay.cwd == work + assert any(event.text == "SYNTHETIC_KILO_FOLLOWUP" for event in replay.events) + assert any(event.text == "SYNTHETIC_NATIVE_REPLY" for event in replay.events) + + +def test_openhands_1160_loads_prefix_and_appends_through_loopback( + tmp_path: Path, +) -> None: + binary = exact_binary( + "SESSION_MIGRATE_OPENHANDS_BIN", + expected_bytes=openhands.PINNED_OPENHANDS_LINUX_X64_BYTES, + expected_sha256=openhands.PINNED_OPENHANDS_LINUX_X64_SHA256, + version_command=["--version"], + expected_version=f"OpenHands CLI {openhands.PINNED_OPENHANDS_VERSION}", + ) + # OpenHands scans ancestors for skills. Pytest's /tmp parent can contain + # unrelated, permission-restricted Unix sockets, so keep the native oracle + # in the checked-out repository while all writable state remains isolated. + work = Path.cwd().resolve() + artifact = convert_session( + portable_session(work, compaction=True), + ConversionOptions( + target_format=TargetFormat.OPENHANDS, + session_id=TARGET_UUID, + cwd=work, + model="openai/fixture-model", + ), + ) + conversations = tmp_path / "conversations" + events_path, _ = install_openhands_artifact(artifact, target_home=conversations) + prefix = {path.name: path.read_bytes() for path in sorted(events_path.glob("event-*.json"))} + + with loopback_server() as (port, handler): + completed = subprocess.run( + [ + str(binary), + "--resume", + artifact.session_id, + "--headless", + "--json", + "--override-with-envs", + "--exit-without-confirmation", + "-t", + "SYNTHETIC_OPENHANDS_FOLLOWUP", + ], + cwd=work, + env={ + **isolated_env(tmp_path), + "OPENHANDS_CONVERSATIONS_DIR": str(conversations), + "OPENHANDS_SUPPRESS_BANNER": "1", + "LLM_API_KEY": "synthetic-not-a-secret", + "LLM_BASE_URL": f"http://127.0.0.1:{port}/v1", + "LLM_MODEL": "openai/fixture-model", + }, + check=False, + capture_output=True, + text=True, + timeout=45, + ) + + assert completed.returncode == 0, (completed.stdout, completed.stderr) + assert "SYNTHETIC_NATIVE_REPLY" in completed.stdout + for name, value in prefix.items(): + assert (events_path / name).read_bytes() == value + assert_request_markers( + handler.requests, + "SYNTHETIC_USER_MARKER", + "synthetic_call_1", + "SYNTHETIC_TOOL_RESULT", + "SYNTHETIC_OPENHANDS_FOLLOWUP", + ) + resumed = openhands.parse_session(events_path) + assert any(event.text == "SYNTHETIC_OPENHANDS_FOLLOWUP" for event in resumed.events) + assert any(event.text == "SYNTHETIC_NATIVE_REPLY" for event in resumed.events) diff --git a/tests/test_openhands_format.py b/tests/test_openhands_format.py index eebb55a..504255d 100644 --- a/tests/test_openhands_format.py +++ b/tests/test_openhands_format.py @@ -1,5 +1,6 @@ import json from pathlib import Path +from uuid import UUID import pytest @@ -67,9 +68,10 @@ def write_native_session(tmp_path: Path) -> Path: tool_call={ "id": "call-openhands-1", "name": "terminal", - "arguments": "{\"command\":\"pwd\"}", + "arguments": '{"command":"pwd"}', "origin": "completion", }, + llm_response_id="00000000-0000-4000-8000-000000000103", ), event( "00000000-0000-4000-8000-000000000004", @@ -114,6 +116,7 @@ def write_native_session(tmp_path: Path) -> Path: "environment", forgotten_event_ids=[], summary="OPENHANDS_SUMMARY", + llm_response_id="00000000-0000-4000-8000-000000000106", ), ] for index, record in enumerate(records): @@ -166,6 +169,8 @@ def test_openhands_writer_round_trips_and_materializes_native_files(tmp_path: Pa assert len(files) == openhands.native_record_count(data) assert files[0][0].startswith("event-00000-") assert json.loads(files[0][1])["kind"] == "SystemPromptEvent" + condensation = next(event for event in parsed.events if event["kind"] == "Condensation") + assert str(UUID(condensation["llm_response_id"])) == condensation["llm_response_id"] assert dropped["openhands_system_prompt"] == 1 assert dropped["openhands_private_thinking"] == 2 assert dropped["openhands_message_runtime_metadata"] == 1 @@ -220,10 +225,10 @@ def test_openhands_writer_preserves_linked_tools_and_user_images(tmp_path: Path) image = next( item for item in records - if item["kind"] == "MessageEvent" - and item["llm_message"]["content"][0]["type"] == "image" + if item["kind"] == "MessageEvent" and item["llm_message"]["content"][0]["type"] == "image" ) assert call["tool_call_id"] == result["tool_call_id"] == "call-1" + assert str(UUID(call["llm_response_id"])) == call["llm_response_id"] assert image["llm_message"]["content"][0]["image_urls"] == [ "data:image/png;base64,c3ludGhldGlj" ] @@ -231,9 +236,7 @@ def test_openhands_writer_preserves_linked_tools_and_user_images(tmp_path: Path) @pytest.mark.parametrize("mutation", ["wrong_id", "gap", "bad_role", "unknown_block"]) -def test_openhands_source_rejects_malformed_logs( - tmp_path: Path, mutation: str -) -> None: +def test_openhands_source_rejects_malformed_logs(tmp_path: Path, mutation: str) -> None: conversation = write_native_session(tmp_path) events = conversation / "events" if mutation == "gap": @@ -266,6 +269,10 @@ def test_openhands_bundle_rejects_duplicate_members_and_wrong_linkage(tmp_path: with pytest.raises(SessionMigrateError, match="valid UTF-8 JSON"): openhands.validate_native_bytes(duplicate.encode(), SESSION_ID) with pytest.raises(SessionMigrateError, match="linkage"): - openhands.validate_native_bytes( - data, "99999999-9999-4999-8999-999999999999" - ) + openhands.validate_native_bytes(data, "99999999-9999-4999-8999-999999999999") + + malformed = json.loads(data) + action = next(event for event in malformed["events"] if event["kind"] == "ActionEvent") + del action["llm_response_id"] + with pytest.raises(SessionMigrateError, match="response id"): + openhands.validate_native_bytes(json.dumps(malformed).encode(), SESSION_ID) diff --git a/tests/test_target_integration.py b/tests/test_target_integration.py index a359d6c..07a54a5 100644 --- a/tests/test_target_integration.py +++ b/tests/test_target_integration.py @@ -1117,17 +1117,27 @@ def test_kilo_official_import_reserves_manifest_and_checks_native_result( ), ) cli = tmp_path / "kilo" - states = iter((set(), set(), {TARGET_OPENCODE_ID})) + states = iter((False, False, True)) monkeypatch.setattr(conversion, "_resolve_kilo_cli", lambda path, env: cli) monkeypatch.setattr( conversion, "_kilo_version", lambda path, env: kilo.PINNED_KILO_VERSION, ) - monkeypatch.setattr(conversion, "_kilo_session_ids", lambda path, env: next(states)) + monkeypatch.setattr( + conversion, + "_kilo_session_exists", + lambda path, session_id, env: next(states), + ) observed: dict[str, object] = {} - def invoke(path: Path, bundle_path: Path, env: dict[str, str]) -> None: + def invoke( + path: Path, + bundle_path: Path, + cwd: Path, + env: dict[str, str], + ) -> None: + assert cwd == tmp_path observed["bytes"] = bundle_path.read_bytes() observed["mode"] = bundle_path.stat().st_mode & 0o777 @@ -1142,6 +1152,40 @@ def invoke(path: Path, bundle_path: Path, env: dict[str, str]) -> None: assert manifest.stat().st_mode & 0o777 == 0o600 +@pytest.mark.parametrize( + ("returncode", "stderr", "expected"), + [ + (0, "Exporting session\n", True), + (1, f"Session not found: {TARGET_OPENCODE_ID}\n", False), + ], +) +def test_kilo_collision_probe_uses_content_free_official_export( + monkeypatch: pytest.MonkeyPatch, + returncode: int, + stderr: str, + expected: bool, +) -> None: + def run(command: list[str], **options: object) -> subprocess.CompletedProcess[str]: + assert command == ["kilo", "export", TARGET_OPENCODE_ID, "--pure"] + assert options["stdout"] is subprocess.DEVNULL + return subprocess.CompletedProcess(command, returncode, stderr=stderr) + + monkeypatch.setattr(conversion.subprocess, "run", run) + assert conversion._kilo_session_exists(Path("kilo"), TARGET_OPENCODE_ID, {}) is expected + + +def test_kilo_collision_probe_fails_closed_on_unexpected_cli_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + conversion.subprocess, + "run", + lambda *args, **kwargs: subprocess.CompletedProcess(args[0], 2, stderr="boom"), + ) + with pytest.raises(SessionMigrateError, match="probe failed"): + conversion._kilo_session_exists(Path("kilo"), TARGET_OPENCODE_ID, {}) + + @pytest.mark.parametrize("target", [TargetFormat.GROK, TargetFormat.OPENHANDS]) def test_directory_targets_install_private_native_files_and_fail_on_collision( tmp_path: Path, target: TargetFormat From 708dfb85f2e3add7198a8171d7386ce1b91040a9 Mon Sep 17 00:00:00 2001 From: xhluca Date: Wed, 26 Aug 2026 14:05:30 -0400 Subject: [PATCH 04/13] fix: harden Grok session reads --- src/session_migrate/formats/grok.py | 137 +++++++++++++++++++++++++--- tests/test_grok_format.py | 100 ++++++++++++++++++++ 2 files changed, 223 insertions(+), 14 deletions(-) diff --git a/src/session_migrate/formats/grok.py b/src/session_migrate/formats/grok.py index 7569499..65e6df8 100644 --- a/src/session_migrate/formats/grok.py +++ b/src/session_migrate/formats/grok.py @@ -5,6 +5,7 @@ import hashlib import json import os +import stat import urllib.parse import uuid from collections import Counter @@ -24,6 +25,8 @@ GROK_BUNDLE_SCHEMA = "session-migrate.grok.v1" MAX_BUNDLE_BYTES = DEFAULT_MAX_TOTAL_BYTES MAX_UPDATES = DEFAULT_MAX_RECORDS +MAX_JSON_DEPTH = 96 +MAX_JSON_NODES = 1_000_000 @dataclass(frozen=True, slots=True) @@ -32,6 +35,15 @@ class ParsedGrokBundle: updates: tuple[dict[str, Any], ...] +@dataclass(frozen=True, slots=True) +class _FileSnapshot: + device: int + inode: int + size: int + modified_ns: int + changed_ns: int + + def serialize( session: Session, *, @@ -226,11 +238,21 @@ def parse_session(path: Path) -> Session: directory = _source_directory(path) summary_path = directory / "summary.json" updates_path = directory / "updates.jsonl" - summary_bytes = _read_bounded(summary_path) - updates_bytes = _read_bounded(updates_path) + snapshots = { + summary_path: _file_snapshot(summary_path), + updates_path: _file_snapshot(updates_path), + } + summary_bytes = _read_bounded(summary_path, snapshots[summary_path]) + updates_bytes = _read_bounded(updates_path, snapshots[updates_path]) + _ensure_files_unchanged(snapshots) try: - summary = json.loads(summary_bytes, object_pairs_hook=_unique_object) - except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + summary = json.loads( + summary_bytes, + object_pairs_hook=_unique_object, + parse_constant=_reject_json_constant, + ) + summary_nodes = _validate_json_shape(summary) + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError, ValueError) as exc: raise JsonlError("Grok summary.json is not valid UTF-8 JSON") from exc if not isinstance(summary, dict): raise JsonlError("Grok summary.json is not a JSON object") @@ -241,7 +263,18 @@ def parse_session(path: Path) -> Session: cwd_value = string(info.get("cwd")) if not cwd_value: raise JsonlError("Grok summary is missing its working directory") - records = _decode_updates(updates_bytes, session_id) + records = _decode_updates( + updates_bytes, + session_id, + max_json_nodes=MAX_JSON_NODES - summary_nodes, + ) + message_count = summary.get("num_messages") + if ( + isinstance(message_count, bool) + or not isinstance(message_count, int) + or message_count != len(records) + ): + raise JsonlError("Grok summary message count does not match updates.jsonl") events: list[Event] = [] for index, record in enumerate(records): events.extend(_parse_update(record, index)) @@ -269,8 +302,13 @@ def validate_native_bytes(data: bytes, session_id: str) -> ParsedGrokBundle: if not data or len(data) > MAX_BUNDLE_BYTES: raise SessionMigrateError("generated Grok bundle is empty or exceeds the safety limit") try: - value = json.loads(data, object_pairs_hook=_unique_object) - except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + value = json.loads( + data, + object_pairs_hook=_unique_object, + parse_constant=_reject_json_constant, + ) + _validate_json_shape(value) + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError, ValueError) as exc: raise SessionMigrateError("generated Grok bundle is not valid UTF-8 JSON") from exc if not isinstance(value, dict) or value.get("schema") != GROK_BUNDLE_SCHEMA: raise SessionMigrateError("generated Grok bundle has an unsupported schema") @@ -425,18 +463,30 @@ def _parse_update(record: dict[str, Any], index: int) -> list[Event]: ] -def _decode_updates(data: bytes, session_id: str) -> list[dict[str, Any]]: +def _decode_updates( + data: bytes, + session_id: str, + *, + max_json_nodes: int = MAX_JSON_NODES, +) -> list[dict[str, Any]]: if len(data) > MAX_BUNDLE_BYTES: raise JsonlError("Grok updates.jsonl exceeds the input safety limit") records = [] + remaining_nodes = max_json_nodes for line_number, line in enumerate(data.splitlines(), start=1): if not line.strip(): continue if len(records) >= MAX_UPDATES: raise JsonlError("Grok update log exceeds the record limit") try: - value = json.loads(line, object_pairs_hook=_unique_object) - except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + value = json.loads( + line, + object_pairs_hook=_unique_object, + parse_constant=_reject_json_constant, + ) + used_nodes = _validate_json_shape(value, max_nodes=remaining_nodes) + remaining_nodes -= used_nodes + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError, ValueError) as exc: raise JsonlError(f"Grok update line {line_number} is not valid JSON") from exc if not isinstance(value, dict) or value.get("method") not in { "session/update", @@ -499,11 +549,19 @@ def _source_directory(path: Path) -> Path: return directory -def _read_bounded(path: Path) -> bytes: - if path.is_symlink() or not path.is_file(): - raise JsonlError("Grok source path is not a regular file") +def _read_bounded(path: Path, expected: _FileSnapshot) -> bytes: try: - data = path.read_bytes() + with path.open("rb") as stream: + opened = _snapshot_from_stat(os.fstat(stream.fileno())) + if opened != expected: + raise JsonlError("Grok session files changed while they were being read; retry") + if opened.size > MAX_BUNDLE_BYTES: + raise JsonlError("Grok session file is empty or exceeds the input safety limit") + data = stream.read(MAX_BUNDLE_BYTES + 1) + if _snapshot_from_stat(os.fstat(stream.fileno())) != expected: + raise JsonlError("Grok session files changed while they were being read; retry") + except JsonlError: + raise except OSError as exc: raise JsonlError(f"cannot read Grok session file: {exc.strerror or exc}") from exc if not data or len(data) > MAX_BUNDLE_BYTES: @@ -511,6 +569,38 @@ def _read_bounded(path: Path) -> bytes: return data +def _file_snapshot(path: Path) -> _FileSnapshot: + try: + info = path.lstat() + except OSError as exc: + raise JsonlError(f"cannot inspect Grok session file: {exc.strerror or exc}") from exc + if not stat.S_ISREG(info.st_mode): + raise JsonlError("Grok source path is not a regular file") + return _snapshot_from_stat(info) + + +def _snapshot_from_stat(info: os.stat_result) -> _FileSnapshot: + return _FileSnapshot( + device=info.st_dev, + inode=info.st_ino, + size=info.st_size, + modified_ns=info.st_mtime_ns, + changed_ns=info.st_ctime_ns, + ) + + +def _ensure_files_unchanged(snapshots: dict[Path, _FileSnapshot]) -> None: + for path, expected in snapshots.items(): + try: + current = _file_snapshot(path) + except JsonlError as exc: + raise JsonlError( + "Grok session files changed while they were being read; retry" + ) from exc + if current != expected: + raise JsonlError("Grok session files changed while they were being read; retry") + + def _uuid(value: Any, label: str) -> str: try: return str(uuid.UUID(str(value))) @@ -527,6 +617,25 @@ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: return result +def _reject_json_constant(value: str) -> None: + raise ValueError(f"unsupported JSON constant: {value}") + + +def _validate_json_shape(value: Any, *, max_nodes: int = MAX_JSON_NODES) -> int: + nodes = 0 + stack: list[tuple[Any, int]] = [(value, 0)] + while stack: + current, depth = stack.pop() + nodes += 1 + if nodes > max_nodes or depth > MAX_JSON_DEPTH: + raise ValueError("Grok JSON structure exceeds the safety limit") + if isinstance(current, dict): + stack.extend((item, depth + 1) for item in current.values()) + elif isinstance(current, list): + stack.extend((item, depth + 1) for item in current) + return nodes + + def _omission_key(event: Event) -> str: if event.kind == EventKind.OPAQUE: return string(event.payload.get("reason")) or "opaque" diff --git a/tests/test_grok_format.py b/tests/test_grok_format.py index 982d8e8..d9ae4dd 100644 --- a/tests/test_grok_format.py +++ b/tests/test_grok_format.py @@ -1,4 +1,5 @@ import json +import os from pathlib import Path import pytest @@ -148,6 +149,10 @@ def test_grok_source_accepts_native_xai_turn_completion(tmp_path: Path) -> None: ) terminal["method"] = "_x.ai/session/update" path.write_text(path.read_text() + json.dumps(terminal) + "\n") + summary_path = session / "summary.json" + summary = json.loads(summary_path.read_text()) + summary["num_messages"] += 1 + summary_path.write_text(json.dumps(summary)) source = grok.parse_session(session) @@ -209,6 +214,101 @@ def test_grok_source_rejects_malformed_updates(tmp_path: Path, mutation: str) -> grok.parse_session(session) +def test_grok_source_rejects_summary_count_mismatch(tmp_path: Path) -> None: + session = write_native_session(tmp_path) + path = session / "summary.json" + summary = json.loads(path.read_text()) + summary["num_messages"] += 1 + path.write_text(json.dumps(summary)) + + with pytest.raises(JsonlError, match="message count does not match"): + grok.parse_session(session) + + +def test_grok_source_rejects_append_during_paired_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + session = write_native_session(tmp_path) + updates_path = session / "updates.jsonl" + original_read = grok._read_bounded + + def append_after_summary(path: Path, expected: object) -> bytes: + data = original_read(path, expected) + if path.name == "summary.json": + with updates_path.open("ab") as stream: + stream.write(json.dumps(envelope({"sessionUpdate": "turn_completed"})).encode()) + stream.write(b"\n") + return data + + monkeypatch.setattr(grok, "_read_bounded", append_after_summary) + + with pytest.raises(JsonlError, match="changed while they were being read"): + grok.parse_session(session) + + +def test_grok_source_rejects_replacement_during_paired_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + session = write_native_session(tmp_path) + updates_path = session / "updates.jsonl" + original_read = grok._read_bounded + + def replace_after_updates(path: Path, expected: object) -> bytes: + data = original_read(path, expected) + if path.name == "updates.jsonl": + replacement = path.with_name("replacement.jsonl") + replacement.write_bytes(data) + os.replace(replacement, updates_path) + return data + + monkeypatch.setattr(grok, "_read_bounded", replace_after_updates) + + with pytest.raises(JsonlError, match="changed while they were being read"): + grok.parse_session(session) + + +@pytest.mark.parametrize("location", ["summary", "update"]) +def test_grok_source_rejects_excessive_json_nesting(tmp_path: Path, location: str) -> None: + session = write_native_session(tmp_path) + nested: object = "leaf" + for _ in range(grok.MAX_JSON_DEPTH + 1): + nested = [nested] + if location == "summary": + path = session / "summary.json" + value = json.loads(path.read_text()) + value["metadata"] = nested + path.write_text(json.dumps(value)) + else: + path = session / "updates.jsonl" + values = [json.loads(line) for line in path.read_text().splitlines()] + values[0]["params"]["update"]["metadata"] = nested + path.write_text("".join(json.dumps(value) + "\n" for value in values)) + + with pytest.raises(JsonlError, match="valid (UTF-8 )?JSON"): + grok.parse_session(session) + + +def test_grok_source_rejects_json_node_budget_exhaustion( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + session = write_native_session(tmp_path) + monkeypatch.setattr(grok, "MAX_JSON_NODES", 32) + + with pytest.raises(JsonlError, match="valid JSON"): + grok.parse_session(session) + + +def test_grok_source_wraps_json_decoder_recursion_error(tmp_path: Path) -> None: + session = write_native_session(tmp_path) + path = session / "summary.json" + summary = json.loads(path.read_text()) + prefix = json.dumps(summary)[:-1] + ',"metadata":' + path.write_text(prefix + "[" * 2_000 + "null" + "]" * 2_000 + "}") + + with pytest.raises(JsonlError, match="valid UTF-8 JSON"): + grok.parse_session(session) + + def test_grok_bundle_rejects_duplicate_json_and_wrong_target(tmp_path: Path) -> None: source = grok.parse_session(write_native_session(tmp_path)) data, _ = grok.serialize(source, session_id=SESSION_ID, cwd=tmp_path) From 477a8c4690b0f1b9299355cb7b5211aa8849d813 Mon Sep 17 00:00:00 2001 From: xhluca Date: Wed, 26 Aug 2026 14:05:54 -0400 Subject: [PATCH 05/13] fix: reject ambiguous OpenCode lineage bundles --- src/session_migrate/inspection.py | 32 +++++++++++++++++++++++++------ tests/test_inspection.py | 32 ++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/session_migrate/inspection.py b/src/session_migrate/inspection.py index dab1a3e..9f0ea21 100644 --- a/src/session_migrate/inspection.py +++ b/src/session_migrate/inspection.py @@ -156,15 +156,18 @@ def inspect_session(path: Path, *, source_format: AgentFormat | None = None) -> before = file_snapshot(path) if source_format in {AgentFormat.OPENCODE, AgentFormat.KILO} or source_format is None: document = _load_json_document(path, before.size) - if document is not None and ( - source_format in {AgentFormat.OPENCODE, AgentFormat.KILO} - or _is_opencode_document(document) - ): + if document is not None and source_format is None and _is_opencode_document(document): + ensure_file_unchanged(path, before) + _raise_opencode_kilo_ambiguity() + if document is not None and source_format in { + AgentFormat.OPENCODE, + AgentFormat.KILO, + }: result = _inspect_opencode( path, before.size, document, - source_format or AgentFormat.OPENCODE, + source_format, ) ensure_file_unchanged(path, before) return result @@ -505,7 +508,8 @@ def detect_path_format(path: Path) -> AgentFormat: before = file_snapshot(path) document = _load_json_document(path, before.size) if document is not None and _is_opencode_document(document): - detected = AgentFormat.OPENCODE + ensure_file_unchanged(path, before) + _raise_opencode_kilo_ambiguity() else: detected = detect_format([record.value for record in iter_jsonl(path)]) ensure_file_unchanged(path, before) @@ -533,6 +537,22 @@ def _is_opencode_document(value: dict[str, Any]) -> bool: ) +def _raise_opencode_kilo_ambiguity() -> None: + """Reject a shared export schema that carries no reliable producer marker. + + Both CLIs persist an imported bundle's ``info.version`` unchanged, so even + their pinned version strings identify who originally created a session, + not which CLI exported it. Treating that field as a discriminator would + silently swap source identities after an OpenCode/Kilo round trip. + """ + + raise FormatDetectionError( + "OpenCode and Kilo export bundles use the same native JSON schema and " + "contain no reliable producer marker; pass --format kilo for a Kilo " + "source or --format opencode for an OpenCode source" + ) + + def _inspect_opencode( path: Path, size: int, value: dict[str, Any], source_format: AgentFormat ) -> Inspection: diff --git a/tests/test_inspection.py b/tests/test_inspection.py index b375007..281bbe3 100644 --- a/tests/test_inspection.py +++ b/tests/test_inspection.py @@ -227,7 +227,12 @@ def test_inspects_opencode_export_document_without_printing_content(tmp_path: Pa ) ) - result = inspect_session(path) + with pytest.raises(FormatDetectionError, match=r"pass --format kilo.*--format opencode"): + inspection.detect_path_format(path) + with pytest.raises(FormatDetectionError, match=r"pass --format kilo.*--format opencode"): + inspect_session(path) + + result = inspect_session(path, source_format=AgentFormat.OPENCODE) assert result.format == "opencode" assert result.session_id == "ses_11111111111141118111111111111111" @@ -238,6 +243,31 @@ def test_inspects_opencode_export_document_without_printing_content(tmp_path: Pa assert "private title" not in result.to_json() +def test_inspects_kilo_export_document_with_explicit_format(tmp_path: Path) -> None: + source = json.loads( + ( + Path(__file__).parent / "fixtures" / "opencode-source-1.17.20" / "comprehensive.json" + ).read_text() + ) + source["info"]["version"] = kilo.PINNED_KILO_VERSION + path = tmp_path / "kilo.json" + path.write_text(json.dumps(source)) + + with pytest.raises(FormatDetectionError, match=r"pass --format kilo.*--format opencode"): + inspection.detect_path_format(path) + with pytest.raises(FormatDetectionError, match=r"pass --format kilo.*--format opencode"): + inspect_session(path) + + result = inspect_session(path, source_format=AgentFormat.KILO) + + assert result.format == "kilo" + assert result.session_id == "ses_33333333333343338333333333333333" + assert result.cli_version == kilo.PINNED_KILO_VERSION + assert result.tool_calls == 1 + assert result.tool_results == 1 + assert "SYNTHETIC_OPENCODE_USER_MARKER" not in result.to_json() + + def test_inspects_copilot_event_log_without_printing_content(tmp_path: Path) -> None: path = write_jsonl( tmp_path / "copilot.jsonl", From d38f874c218a9d535382676e0ee8c542949ee9bc Mon Sep 17 00:00:00 2001 From: xhluca Date: Wed, 26 Aug 2026 14:11:16 -0400 Subject: [PATCH 06/13] fix: harden OpenHands native session state --- src/session_migrate/catalog.py | 12 +- src/session_migrate/conversion.py | 2 +- src/session_migrate/formats/openhands.py | 275 ++++++++++++++++++++--- tests/test_catalog.py | 24 ++ tests/test_grok_kilo_openhands_native.py | 17 ++ tests/test_openhands_format.py | 145 ++++++++++++ tests/test_target_integration.py | 1 + 7 files changed, 449 insertions(+), 27 deletions(-) diff --git a/src/session_migrate/catalog.py b/src/session_migrate/catalog.py index 0ccfb73..2e3f246 100644 --- a/src/session_migrate/catalog.py +++ b/src/session_migrate/catalog.py @@ -23,7 +23,7 @@ from session_migrate.conversion import ConversionOptions, convert_session, load_session from session_migrate.errors import JsonlError, SessionMigrateError -from session_migrate.formats import antigravity, kimi, omp, vibe +from session_migrate.formats import antigravity, kimi, omp, openhands, vibe from session_migrate.formats import cursor as cursor_format from session_migrate.jsonl import ( DEFAULT_MAX_TOTAL_BYTES, @@ -2391,6 +2391,16 @@ def _sqlite_session_snapshot(path: Path, format_name: str) -> _VirtualSnapshot: def _directory_session_snapshot(path: Path, format_name: str) -> _VirtualSnapshot: """Track all authoritative files of a directory-backed native session.""" + if format_name == AgentFormat.OPENHANDS.value: + snapshot = openhands.session_snapshot(path) + return _VirtualSnapshot( + snapshot.device, + snapshot.inode, + snapshot.size, + snapshot.modified_ns, + snapshot.fingerprint, + ) + try: directory = path.lstat() except OSError as exc: diff --git a/src/session_migrate/conversion.py b/src/session_migrate/conversion.py index 3d4d689..fa88a36 100644 --- a/src/session_migrate/conversion.py +++ b/src/session_migrate/conversion.py @@ -1091,7 +1091,7 @@ def install_openhands_artifact( target_home: Path, dry_run: bool = False, ) -> tuple[Path, Path]: - """Install the canonical OpenHands event log; runtime state is rebuilt on resume.""" + """Install events only; pinned SDK 1.21.0 rebuilds complete runtime state on resume.""" if artifact.target_format != TargetFormat.OPENHANDS: raise SessionMigrateError("OpenHands installation requires an OpenHands artifact") diff --git a/src/session_migrate/formats/openhands.py b/src/session_migrate/formats/openhands.py index 1ca09df..f7e7558 100644 --- a/src/session_migrate/formats/openhands.py +++ b/src/session_migrate/formats/openhands.py @@ -2,9 +2,9 @@ OpenHands resumes a conversation from ordered JSON event files below ``~/.openhands/conversations//events``. ``base_state.json`` is a -derived runtime cache: the pinned CLI rebuilds it when only the event log is -present, so migration never copies credentials, provider settings, or cached -runtime state. +complete SDK runtime snapshot, not a metadata sidecar: the pinned CLI rebuilds +it when only the event log is present, so migration never copies credentials, +provider settings, or cached runtime state. """ from __future__ import annotations @@ -15,6 +15,7 @@ import re import uuid from collections import Counter +from collections.abc import Iterable from dataclasses import dataclass from datetime import UTC, datetime, timedelta from pathlib import Path @@ -22,7 +23,11 @@ from session_migrate.errors import JsonlError, SessionMigrateError from session_migrate.formats.common import content_text, portable_data_image, string, valid_rfc3339 -from session_migrate.jsonl import DEFAULT_MAX_RECORDS, DEFAULT_MAX_TOTAL_BYTES +from session_migrate.jsonl import ( + DEFAULT_MAX_RECORD_BYTES, + DEFAULT_MAX_RECORDS, + DEFAULT_MAX_TOTAL_BYTES, +) from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session PINNED_OPENHANDS_VERSION = "1.16.0" @@ -31,7 +36,9 @@ "cb04ee2da91c698733d5201c55cbc08d81dccc9d64b666275abf68a4e0c590e3" ) OPENHANDS_BUNDLE_SCHEMA = "session-migrate.openhands.v1" +OPENHANDS_BASE_STATE_POLICY = "runtime-rebuilt" MAX_BUNDLE_BYTES = DEFAULT_MAX_TOTAL_BYTES +MAX_BASE_STATE_BYTES = DEFAULT_MAX_RECORD_BYTES MAX_EVENTS = DEFAULT_MAX_RECORDS MAX_JSON_DEPTH = 96 MAX_JSON_NODES = 1_000_000 @@ -43,10 +50,24 @@ class ParsedOpenHandsBundle: session_id: str cwd: Path cli_version: str + model: str | None title: str | None + picker_title: str | None + base_state_policy: str events: tuple[dict[str, Any], ...] +@dataclass(frozen=True, slots=True) +class OpenHandsSessionSnapshot: + """Content-free identity of the event log and optional runtime snapshot.""" + + device: int + inode: int + size: int + modified_ns: int + fingerprint: str + + def serialize( session: Session, *, @@ -66,8 +87,7 @@ def serialize( records: list[dict[str, Any]] = [] seen_calls: set[str] = set() seen_results: set[str] = set() - action_ids: dict[str, str] = {} - tool_names: dict[str, str] = {} + pending_actions: dict[str, list[tuple[str, str, str]]] = {} def next_record(kind: str, source: str, **fields: Any) -> dict[str, Any]: nonlocal clock @@ -142,11 +162,13 @@ def next_record(kind: str, source: str, **fields: Any) -> dict[str, Any]: continue if event.kind == EventKind.TOOL_CALL: - call_id = event.tool_call_id or f"call_session_migrate_{uuid.uuid4().hex}" + source_call_id = event.tool_call_id + call_id = source_call_id or f"call_session_migrate_{uuid.uuid4().hex}" if not event.tool_call_id: dropped["tool_call:missing_id"] += 1 if call_id in seen_calls: dropped["tool_call:duplicate_id"] += 1 + call_id = f"{call_id}__session_migrate_{uuid.uuid4().hex}" seen_calls.add(call_id) name = event.tool_name or "unknown_tool" if not event.tool_name: @@ -183,25 +205,32 @@ def next_record(kind: str, source: str, **fields: Any) -> dict[str, Any]: security_risk="LOW", summary=f"Imported {name} call", ) - action_ids.setdefault(call_id, native["id"]) - tool_names.setdefault(call_id, name) + pending_key = source_call_id or call_id + pending_actions.setdefault(pending_key, []).append((call_id, native["id"], name)) continue if event.kind == EventKind.TOOL_RESULT: - call_id = event.tool_call_id or f"call_missing_{uuid.uuid4().hex}" + source_call_id = event.tool_call_id if not event.tool_call_id: dropped["tool_result:missing_id"] += 1 - elif call_id not in seen_calls: + continue + if source_call_id not in pending_actions or not pending_actions[source_call_id]: dropped["tool_result:orphan_id"] += 1 - if event.tool_call_id and call_id in seen_results: + if source_call_id in seen_results: + dropped["tool_result:duplicate_id"] += 1 + seen_results.add(source_call_id) + continue + if source_call_id in seen_results: dropped["tool_result:duplicate_id"] += 1 - if event.tool_call_id: - seen_results.add(call_id) + seen_results.add(source_call_id) + call_id, action_id, action_name = pending_actions[source_call_id].pop(0) + if event.tool_name and event.tool_name != action_name: + dropped["tool_result:name_mismatch"] += 1 content = _tool_result_content(event, dropped) next_record( "ObservationEvent", "environment", - tool_name=event.tool_name or tool_names.get(call_id) or "unknown_tool", + tool_name=action_name, tool_call_id=call_id, observation={ "content": content, @@ -212,7 +241,7 @@ def next_record(kind: str, source: str, **fields: Any) -> dict[str, Any]: "metadata": {}, "kind": "TerminalObservation", }, - action_id=action_ids.get(call_id, str(uuid.uuid4())), + action_id=action_id, ) continue @@ -242,6 +271,7 @@ def next_record(kind: str, source: str, **fields: Any) -> dict[str, Any]: raise SessionMigrateError("conversion produced no resumable conversation history") bundle = { "schema": OPENHANDS_BUNDLE_SCHEMA, + "base_state_policy": OPENHANDS_BASE_STATE_POLICY, "session_id": canonical_id, "cwd": str(cwd), "cli_version": cli_version, @@ -260,7 +290,12 @@ def parse_session(path: Path) -> Session: """Parse one native OpenHands conversation directory or events directory.""" conversation, events_dir = _source_paths(path) + before = session_snapshot(events_dir) entries = _read_event_files(events_dir) + base_state, base_state_bytes = _read_base_state(conversation) + after = session_snapshot(events_dir) + if after != before: + raise JsonlError("OpenHands session changed while it was being read; retry") events: list[Event] = [] for index, (_, value) in enumerate(entries): events.extend(_parse_event(value, index)) @@ -271,7 +306,11 @@ def parse_session(path: Path) -> Session: digest.update(b"\0") digest.update(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()) digest.update(b"\0") - model, cwd = _derived_state_metadata(conversation) + if base_state_bytes is not None: + digest.update(b"base_state.json\0") + digest.update(base_state_bytes) + digest.update(b"\0") + model, cwd = _derived_state_metadata(base_state, conversation.name) return Session( source_format=AgentFormat.OPENHANDS, source_path=conversation.resolve(), @@ -281,7 +320,7 @@ def parse_session(path: Path) -> Session: started_at=_portable_timestamp(first_timestamp), cli_version=PINNED_OPENHANDS_VERSION, model=model, - title=None, + title=_native_picker_title(value for _, value in entries), events=tuple(events), raw_record_count=len(entries), model_provider=model.split("/", 1)[0] if model and "/" in model else None, @@ -308,13 +347,25 @@ def validate_native_bytes(data: bytes, session_id: str) -> ParsedOpenHandsBundle raise SessionMigrateError("generated OpenHands bundle session linkage is invalid") cwd = string(value.get("cwd")) version = string(value.get("cli_version")) + model = string(value.get("model")) + title = string(value.get("title")) + base_state_policy = string(value.get("base_state_policy")) or OPENHANDS_BASE_STATE_POLICY events = value.get("events") - if not cwd or "\x00" in cwd or not version or not isinstance(events, list): + if ( + not cwd + or "\x00" in cwd + or not version + or (model is not None and "\x00" in model) + or (title is not None and "\x00" in title) + or base_state_policy != OPENHANDS_BASE_STATE_POLICY + or not isinstance(events, list) + ): raise SessionMigrateError("generated OpenHands bundle has invalid metadata") if not events or len(events) > MAX_EVENTS or not all(isinstance(item, dict) for item in events): raise SessionMigrateError("generated OpenHands bundle has invalid events") for index, event in enumerate(events): _validate_event(event, index) + _validate_event_sequence(events) if events[0].get("kind") != "SystemPromptEvent": raise SessionMigrateError("generated OpenHands history must start with a system event") if not any(event.get("kind") in {"MessageEvent", "ActionEvent"} for event in events[1:]): @@ -325,7 +376,10 @@ def validate_native_bytes(data: bytes, session_id: str) -> ParsedOpenHandsBundle session_id=canonical_id, cwd=Path(cwd), cli_version=version, - title=string(value.get("title")), + model=model, + title=title, + picker_title=_native_picker_title(events), + base_state_policy=base_state_policy, events=tuple(dict(event) for event in events), ) @@ -337,6 +391,15 @@ def native_record_count(data: bytes) -> int: def native_files(data: bytes, session_id: str) -> tuple[tuple[str, bytes], ...]: + """Return event files only; SDK 1.21.0 rebuilds ``base_state.json`` on resume. + + The base state contains the complete agent configuration and credential + fields. It has no title or CLI-version field, and installing a partial + value makes the pinned SDK take its strict restore path. Target cwd/model + are therefore carried in the validated bundle and supplied at first + resume; the SDK then persists its own complete, redacted runtime snapshot. + """ + parsed = validate_native_bytes(data, session_id) files = [] for index, event in enumerate(parsed.events): @@ -346,6 +409,67 @@ def native_files(data: bytes, session_id: str) -> tuple[tuple[str, bytes], ...]: return tuple(files) +def session_snapshot(path: Path) -> OpenHandsSessionSnapshot: + """Snapshot the complete authoritative session state without reading bodies.""" + + conversation, events_dir = _source_paths(path) + try: + directory = events_dir.lstat() + conversation_stat = conversation.lstat() + except OSError as exc: + raise JsonlError("OpenHands session directory is unavailable") from exc + if ( + events_dir.is_symlink() + or not events_dir.is_dir() + or conversation.is_symlink() + or not conversation.is_dir() + ): + raise JsonlError("OpenHands session directory is invalid") + + event_paths = tuple(sorted(events_dir.glob("event-*.json"))) + if not event_paths or len(event_paths) > MAX_EVENTS: + raise JsonlError("OpenHands event log is empty or exceeds the record limit") + candidates = list(event_paths) + base_state_path = conversation / "base_state.json" + if os.path.lexists(base_state_path): + candidates.append(base_state_path) + + components = [ + ( + f"conversation:{conversation_stat.st_dev}:{conversation_stat.st_ino}:" + f"{conversation_stat.st_mtime_ns}" + ), + f"events:{directory.st_dev}:{directory.st_ino}:{directory.st_mtime_ns}", + ] + total_size = 0 + newest = max(directory.st_mtime_ns, conversation_stat.st_mtime_ns) + for candidate in candidates: + try: + info = candidate.lstat() + except OSError as exc: + raise JsonlError("OpenHands session state is unavailable") from exc + if candidate.is_symlink() or not candidate.is_file(): + raise JsonlError("OpenHands session state is not a regular file") + if candidate == base_state_path and info.st_size > MAX_BASE_STATE_BYTES: + raise JsonlError("OpenHands base state exceeds the input safety limit") + total_size += info.st_size + if total_size > MAX_BUNDLE_BYTES + MAX_BASE_STATE_BYTES: + raise JsonlError("OpenHands session exceeds the input safety limit") + newest = max(newest, info.st_mtime_ns) + components.append( + f"{candidate.relative_to(conversation)}:{info.st_dev}:{info.st_ino}:" + f"{info.st_size}:{info.st_mtime_ns}" + ) + fingerprint = hashlib.sha256("\0".join(components).encode()).hexdigest() + return OpenHandsSessionSnapshot( + directory.st_dev, + directory.st_ino, + total_size, + newest, + fingerprint, + ) + + def session_relative_path(session_id: str) -> Path: return Path(_uuid(session_id, "OpenHands target session ID").replace("-", "")) / "events" @@ -403,6 +527,7 @@ def _read_event_files(events_dir: Path) -> list[tuple[str, dict[str, Any]]]: entries.append((path.name, value)) if entries[0][1].get("kind") != "SystemPromptEvent": raise JsonlError("OpenHands event log must start with a system event") + _validate_event_sequence([value for _, value in entries]) return entries @@ -556,6 +681,38 @@ def _validate_event(value: dict[str, Any], index: int) -> None: _uuid(value.get("llm_response_id"), "OpenHands condensation response id") +def _validate_event_sequence(events: list[dict[str, Any]]) -> None: + """Enforce the cross-event invariants consumed by SDK 1.21.0.""" + + event_ids: set[str] = set() + actions: dict[str, tuple[str, str]] = {} + observed_actions: set[str] = set() + for index, event in enumerate(events): + event_id = str(event["id"]) + if event_id in event_ids: + raise SessionMigrateError(f"OpenHands event {index} has a duplicate id") + event_ids.add(event_id) + if event.get("kind") == "ActionEvent": + tool_call_id = str(event["tool_call_id"]) + actions[event_id] = (tool_call_id, str(event["tool_name"])) + elif event.get("kind") == "ObservationEvent": + action_id = str(event.get("action_id")) + action = actions.get(action_id) + if action is None: + raise SessionMigrateError( + f"OpenHands observation event {index} has invalid action linkage" + ) + if action_id in observed_actions: + raise SessionMigrateError( + f"OpenHands observation event {index} duplicates an action result" + ) + if action != (str(event["tool_call_id"]), str(event["tool_name"])): + raise SessionMigrateError( + f"OpenHands observation event {index} disagrees with its action" + ) + observed_actions.add(action_id) + + def _validate_content(content: Any) -> None: if not isinstance(content, list) or not content: raise SessionMigrateError("OpenHands content is empty or malformed") @@ -666,16 +823,46 @@ def _text_content(value: Any) -> str | None: return None -def _derived_state_metadata(conversation: Path) -> tuple[str | None, Path | None]: +def _read_base_state(conversation: Path) -> tuple[dict[str, Any] | None, bytes | None]: path = conversation / "base_state.json" - if not path.is_file() or path.is_symlink(): + if not os.path.lexists(path): return None, None try: - value = json.loads(path.read_bytes()) - except (OSError, UnicodeDecodeError, json.JSONDecodeError): - return None, None + before = path.lstat() + if path.is_symlink() or not path.is_file(): + raise JsonlError("OpenHands base state is not a regular file") + if before.st_size > MAX_BASE_STATE_BYTES: + raise JsonlError("OpenHands base state exceeds the input safety limit") + with path.open("rb") as stream: + opened = os.fstat(stream.fileno()) + if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino): + raise JsonlError("OpenHands base state changed while it was opened; retry") + data = stream.read(MAX_BASE_STATE_BYTES + 1) + if len(data) > MAX_BASE_STATE_BYTES: + raise JsonlError("OpenHands base state exceeds the input safety limit") + value = json.loads( + data, + object_pairs_hook=_unique_object, + parse_constant=_reject_json_constant, + ) + _validate_json_bounds(value) + except JsonlError: + raise + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise JsonlError("OpenHands base state is not valid bounded UTF-8 JSON") from exc if not isinstance(value, dict): + raise JsonlError("OpenHands base state is not a JSON object") + return value, data + + +def _derived_state_metadata( + value: dict[str, Any] | None, conversation_name: str +) -> tuple[str | None, Path | None]: + if value is None: return None, None + state_id = _uuid(value.get("id"), "OpenHands base state id") + if state_id != _uuid(conversation_name, "OpenHands conversation directory"): + raise JsonlError("OpenHands base state and conversation directory disagree") agent = value.get("agent") llm = agent.get("llm") if isinstance(agent, dict) else None model = string(llm.get("model")) if isinstance(llm, dict) else None @@ -683,9 +870,29 @@ def _derived_state_metadata(conversation: Path) -> tuple[str | None, Path | None cwd_value = None if isinstance(workspace, dict): cwd_value = string(workspace.get("working_dir")) or string(workspace.get("cwd")) + if (model and "\x00" in model) or (cwd_value and "\x00" in cwd_value): + raise JsonlError("OpenHands base state metadata is invalid") return model, Path(cwd_value) if cwd_value else None +def _native_picker_title(events: Iterable[dict[str, Any]]) -> str | None: + """Match OpenHands CLI 1.16.0's first-user-text picker title.""" + + for event in events: + if event.get("kind") != "MessageEvent" or event.get("source") != "user": + continue + message = event.get("llm_message") + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list) or not content: + continue + first = content[0] + if isinstance(first, dict) and first.get("type") == "text": + title = string(first.get("text")) + if title: + return title + return None + + def _portable_timestamp(value: Any) -> str | None: if not isinstance(value, str): return None @@ -740,6 +947,24 @@ def _validate_json_shape(value: Any) -> None: raise ValueError("non-finite or fractional numeric metadata") +def _validate_json_bounds(value: Any) -> None: + nodes = 0 + stack: list[tuple[Any, int]] = [(value, 0)] + while stack: + current, depth = stack.pop() + nodes += 1 + if nodes > MAX_JSON_NODES or depth > MAX_JSON_DEPTH: + raise ValueError("JSON structure exceeds safety limit") + if isinstance(current, dict): + stack.extend((item, depth + 1) for item in current.values()) + elif isinstance(current, list): + stack.extend((item, depth + 1) for item in current) + + +def _reject_json_constant(value: str) -> None: + raise ValueError(f"unsupported JSON constant: {value}") + + def _omission_key(event: Event) -> str: if event.kind == EventKind.OPAQUE: return string(event.payload.get("reason")) or "opaque" diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 7a9ca6c..4763c86 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -600,6 +600,16 @@ def test_grok_kilo_and_openhands_catalog_roots_are_complete_searchable_and_trans openhands_events.mkdir(parents=True) for name, data in openhands.native_files(openhands_bytes, OPENHANDS_ID): (openhands_events / name).write_bytes(data) + openhands_state = openhands_events.parent / "base_state.json" + openhands_state.write_text( + json.dumps( + { + "id": OPENHANDS_ID, + "agent": {"llm": {"model": "openai/catalog-fixture"}}, + "workspace": {"working_dir": str(tmp_path), "kind": "LocalWorkspace"}, + } + ) + ) kilo_home = tmp_path / "kilo-data" connection = _opencode_database(kilo_home, "kilo.db") @@ -619,6 +629,9 @@ def test_grok_kilo_and_openhands_catalog_roots_are_complete_searchable_and_trans assert first.root_errors == 0 assert len(catalog.list_sessions(query="timeline merging")) == 1 assert len(catalog.list_sessions(query="catalog keyword")) == 1 + openhands_matches = catalog.list_sessions(query="synthetic migrator nonce") + assert len(openhands_matches) == 1 + assert openhands_matches[0].format == "openhands" entries = catalog.list_sessions(limit=10) assert {entry.format for entry in entries} == {"grok", "kilo", "openhands"} @@ -634,6 +647,17 @@ def test_grok_kilo_and_openhands_catalog_roots_are_complete_searchable_and_trans assert second.unchanged == 3 assert second.scanned == 0 + state = json.loads(openhands_state.read_text()) + changed_cwd = tmp_path / "changed-workspace" + state["workspace"]["working_dir"] = str(changed_cwd) + openhands_state.write_text(json.dumps(state)) + third = catalog.refresh(include_auto=False) + assert third.scanned == 1 + assert third.unchanged == 2 + assert catalog.list_sessions(query="synthetic migrator nonce", include_paths=True)[ + 0 + ].cwd == str(changed_cwd) + def test_copilot_inventory_includes_valid_corrupt_missing_and_symlinked_logs( tmp_path: Path, diff --git a/tests/test_grok_kilo_openhands_native.py b/tests/test_grok_kilo_openhands_native.py index 225069e..6253733 100644 --- a/tests/test_grok_kilo_openhands_native.py +++ b/tests/test_grok_kilo_openhands_native.py @@ -416,6 +416,14 @@ def test_openhands_1160_loads_prefix_and_appends_through_loopback( ) conversations = tmp_path / "conversations" events_path, _ = install_openhands_artifact(artifact, target_home=conversations) + bundle = openhands.validate_native_bytes(artifact.native_bytes, artifact.session_id) + assert bundle.cwd == work + assert bundle.model == "openai/fixture-model" + assert bundle.title == "SYNTHETIC_IMPORTED_NAME" + assert bundle.picker_title == "SYNTHETIC_USER_MARKER" + assert bundle.cli_version == openhands.PINNED_OPENHANDS_VERSION + assert bundle.base_state_policy == openhands.OPENHANDS_BASE_STATE_POLICY + assert not (events_path.parent / "base_state.json").exists() prefix = {path.name: path.read_bytes() for path in sorted(events_path.glob("event-*.json"))} with loopback_server() as (port, handler): @@ -457,6 +465,15 @@ def test_openhands_1160_loads_prefix_and_appends_through_loopback( "SYNTHETIC_TOOL_RESULT", "SYNTHETIC_OPENHANDS_FOLLOWUP", ) + base_state = json.loads((events_path.parent / "base_state.json").read_text()) + assert base_state["id"] == artifact.session_id + assert base_state["workspace"]["working_dir"] == str(work) + assert base_state["agent"]["llm"]["model"] == "openai/fixture-model" + assert "title" not in base_state + assert "cli_version" not in base_state resumed = openhands.parse_session(events_path) + assert resumed.title == "SYNTHETIC_USER_MARKER" + assert resumed.cwd == work + assert resumed.model == "openai/fixture-model" assert any(event.text == "SYNTHETIC_OPENHANDS_FOLLOWUP" for event in resumed.events) assert any(event.text == "SYNTHETIC_NATIVE_REPLY" for event in resumed.events) diff --git a/tests/test_openhands_format.py b/tests/test_openhands_format.py index 504255d..b7e78a1 100644 --- a/tests/test_openhands_format.py +++ b/tests/test_openhands_format.py @@ -25,6 +25,15 @@ def write_native_session(tmp_path: Path) -> Path: conversation = tmp_path / SESSION_ID.replace("-", "") events = conversation / "events" events.mkdir(parents=True) + (conversation / "base_state.json").write_text( + json.dumps( + { + "id": SESSION_ID, + "agent": {"llm": {"model": "openai/native-fixture"}}, + "workspace": {"working_dir": str(tmp_path), "kind": "LocalWorkspace"}, + } + ) + ) records = [ event( "00000000-0000-4000-8000-000000000001", @@ -130,6 +139,9 @@ def test_openhands_source_projects_messages_tools_media_and_compaction(tmp_path: assert session.source_format == AgentFormat.OPENHANDS assert session.session_id == SESSION_ID + assert session.cwd == tmp_path + assert session.model == "openai/native-fixture" + assert session.title == "OPENHANDS_USER" assert session.raw_record_count == 6 assert session.event_counts() == { "compaction": 1, @@ -166,7 +178,11 @@ def test_openhands_writer_round_trips_and_materializes_native_files(tmp_path: Pa assert parsed.session_id == target_id assert parsed.title == "Synthetic migrated session" + assert parsed.picker_title == "OPENHANDS_USER" + assert parsed.model == "openai/native-fixture" + assert parsed.base_state_policy == openhands.OPENHANDS_BASE_STATE_POLICY assert len(files) == openhands.native_record_count(data) + assert all(name != "base_state.json" for name, _ in files) assert files[0][0].startswith("event-00000-") assert json.loads(files[0][1])["kind"] == "SystemPromptEvent" condensation = next(event for event in parsed.events if event["kind"] == "Condensation") @@ -276,3 +292,132 @@ def test_openhands_bundle_rejects_duplicate_members_and_wrong_linkage(tmp_path: del action["llm_response_id"] with pytest.raises(SessionMigrateError, match="response id"): openhands.validate_native_bytes(json.dumps(malformed).encode(), SESSION_ID) + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("duplicate_event_id", "duplicate id"), + ("dangling_action", "action linkage"), + ("mismatched_tool_call", "disagrees with its action"), + ], +) +def test_openhands_rejects_invalid_cross_event_linkage( + tmp_path: Path, mutation: str, message: str +) -> None: + source = openhands.parse_session(write_native_session(tmp_path)) + data, _ = openhands.serialize(source, session_id=SESSION_ID, cwd=tmp_path) + malformed = json.loads(data) + events = malformed["events"] + observation = next(item for item in events if item["kind"] == "ObservationEvent") + if mutation == "duplicate_event_id": + observation["id"] = events[0]["id"] + elif mutation == "dangling_action": + observation["action_id"] = "99999999-9999-4999-8999-999999999999" + else: + observation["tool_call_id"] = "different-call" + + with pytest.raises(SessionMigrateError, match=message): + openhands.validate_native_bytes(json.dumps(malformed).encode(), SESSION_ID) + + +def test_openhands_normalizes_duplicate_calls_and_drops_orphan_results(tmp_path: Path) -> None: + source = Session( + source_format=AgentFormat.CLAUDE, + source_path=tmp_path / "source.jsonl", + source_sha256="0" * 64, + session_id=None, + cwd=tmp_path, + started_at="2026-08-26T12:00:00Z", + cli_version=None, + model=None, + title=None, + events=( + Event(EventKind.MESSAGE, Provenance(0), role=Role.USER, text="start"), + Event( + EventKind.TOOL_CALL, + Provenance(1), + role=Role.ASSISTANT, + tool_name="read", + tool_call_id="duplicate", + payload={"input": {"path": "a"}}, + ), + Event( + EventKind.TOOL_CALL, + Provenance(2), + role=Role.ASSISTANT, + tool_name="read", + tool_call_id="duplicate", + payload={"input": {"path": "b"}}, + ), + Event( + EventKind.TOOL_RESULT, + Provenance(3), + role=Role.TOOL, + tool_name="read", + tool_call_id="duplicate", + text="one", + ), + Event( + EventKind.TOOL_RESULT, + Provenance(4), + role=Role.TOOL, + tool_name="read", + tool_call_id="duplicate", + text="two", + ), + Event( + EventKind.TOOL_RESULT, + Provenance(5), + role=Role.TOOL, + tool_name="read", + tool_call_id="orphan", + text="must not reach native history", + ), + ), + raw_record_count=6, + ) + + data, dropped = openhands.serialize(source, session_id=SESSION_ID, cwd=tmp_path) + records = openhands.validate_native_bytes(data, SESSION_ID).events + actions = [item for item in records if item["kind"] == "ActionEvent"] + observations = [item for item in records if item["kind"] == "ObservationEvent"] + + assert len({item["id"] for item in records}) == len(records) + assert len({item["tool_call_id"] for item in actions}) == 2 + assert [item["action_id"] for item in observations] == [item["id"] for item in actions] + assert "must not reach native history" not in data.decode() + assert dropped == { + "tool_call:duplicate_id": 1, + "tool_result:duplicate_id": 1, + "tool_result:orphan_id": 1, + } + + +def test_openhands_base_state_is_bounded_and_part_of_the_coherent_snapshot( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + conversation = write_native_session(tmp_path) + base_state = conversation / "base_state.json" + before = openhands.session_snapshot(conversation) + value = json.loads(base_state.read_text()) + value["agent"]["llm"]["model"] = "openai/changed" + base_state.write_text(json.dumps(value)) + assert openhands.session_snapshot(conversation) != before + + original = openhands._read_base_state + + def mutate_after_read(path: Path) -> tuple[dict[str, object] | None, bytes | None]: + result = original(path) + base_state.write_bytes(base_state.read_bytes() + b" ") + return result + + monkeypatch.setattr(openhands, "_read_base_state", mutate_after_read) + with pytest.raises(JsonlError, match="changed while it was being read"): + openhands.parse_session(conversation) + + monkeypatch.setattr(openhands, "_read_base_state", original) + with base_state.open("wb") as stream: + stream.truncate(openhands.MAX_BASE_STATE_BYTES + 1) + with pytest.raises(JsonlError, match="base state exceeds"): + openhands.parse_session(conversation) diff --git a/tests/test_target_integration.py b/tests/test_target_integration.py index 07a54a5..a1a2117 100644 --- a/tests/test_target_integration.py +++ b/tests/test_target_integration.py @@ -1208,6 +1208,7 @@ def test_directory_targets_install_private_native_files_and_fail_on_collision( else: parsed = openhands.parse_session(installed_native) native_files = tuple(installed_native.glob("event-*.json")) + assert not (installed_native.parent / "base_state.json").exists() assert parsed.session_id == TARGET_UUID assert native_files assert all(path.stat().st_mode & 0o777 == 0o600 for path in native_files) From 7c4ccdc69532310e8c49fafe21c1567757fbc418 Mon Sep 17 00:00:00 2001 From: xhluca Date: Wed, 26 Aug 2026 14:38:00 -0400 Subject: [PATCH 07/13] test: exercise Grok Kilo and OpenHands terminal UIs --- tests/test_grok_kilo_openhands_native.py | 148 +++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/tests/test_grok_kilo_openhands_native.py b/tests/test_grok_kilo_openhands_native.py index 6253733..fb52133 100644 --- a/tests/test_grok_kilo_openhands_native.py +++ b/tests/test_grok_kilo_openhands_native.py @@ -10,10 +10,17 @@ import json import os +import pty +import select +import signal +import struct import subprocess +import termios import threading +import time from collections.abc import Iterator from contextlib import contextmanager, suppress +from fcntl import ioctl from hashlib import file_digest from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -212,6 +219,64 @@ def assert_request_markers(requests: list[dict[str, Any]], *markers: str) -> Non assert marker in replay +def native_tui_transcript( + command: list[str], + *, + cwd: Path, + env: dict[str, str], + markers: tuple[str, ...], + timeout: float = 20, +) -> str: + """Run an exact native interactive UI in a bounded Linux PTY.""" + + master, slave = pty.openpty() + ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 48, 140, 0, 0)) + process = subprocess.Popen( + command, + cwd=cwd, + env={**env, "TERM": "xterm-256color", "COLORTERM": "truecolor"}, + stdin=slave, + stdout=slave, + stderr=slave, + start_new_session=True, + ) + os.close(slave) + output = bytearray() + deadline = time.monotonic() + timeout + try: + while time.monotonic() < deadline: + ready, _, _ = select.select([master], [], [], 0.2) + if ready: + try: + output.extend(os.read(master, 65_536)) + except OSError: + break + if all(marker.encode() in output for marker in markers): + break + if process.poll() is not None: + break + finally: + if process.poll() is None: + with suppress(OSError): + os.write(master, b"\x03") + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + with suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + with suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=2) + os.close(master) + transcript = output.decode(errors="replace") + missing = [marker for marker in markers if marker not in transcript] + assert not missing, f"native TUI omitted {missing}: {transcript[-4000:]}" + return transcript + + def test_grok_105_loads_prefix_and_appends_through_loopback(tmp_path: Path) -> None: binary = exact_binary( "SESSION_MIGRATE_GROK_BIN", @@ -289,6 +354,26 @@ def test_grok_105_loads_prefix_and_appends_through_loopback(tmp_path: Path) -> N event.kind == EventKind.MESSAGE and event.text == "SYNTHETIC_NATIVE_REPLY" for event in resumed.events ) + native_tui_transcript( + [ + str(binary), + "--resume", + artifact.session_id, + "--cwd", + str(work), + "--model", + "fixture-model", + "--fullscreen", + "--disable-web-search", + ], + cwd=work, + env={**isolated_env(tmp_path), "GROK_HOME": str(grok_home)}, + markers=( + "SYNTHETIC_COMPACTION_MARKER", + "SYNTHETIC_FINAL_MARKER", + "SYNTHETIC_NATIVE_REPLY", + ), + ) def test_kilo_750_official_import_replay_and_export(tmp_path: Path) -> None: @@ -389,6 +474,27 @@ def test_kilo_750_official_import_replay_and_export(tmp_path: Path) -> None: assert replay.cwd == work assert any(event.text == "SYNTHETIC_KILO_FOLLOWUP" for event in replay.events) assert any(event.text == "SYNTHETIC_NATIVE_REPLY" for event in replay.events) + native_tui_transcript( + [ + str(binary), + str(work), + "--session", + artifact.session_id, + "--model", + "fixture/fixture-model", + "--pure", + "--mini", + "--replay-limit", + "50", + ], + cwd=work, + env={**env, "KILO_CONFIG_CONTENT": json.dumps(config)}, + markers=( + "SYNTHETIC_COMPACTION_MARKER", + "SYNTHETIC_FINAL_MARKER", + "SYNTHETIC_NATIVE_REPLY", + ), + ) def test_openhands_1160_loads_prefix_and_appends_through_loopback( @@ -477,3 +583,45 @@ def test_openhands_1160_loads_prefix_and_appends_through_loopback( assert resumed.model == "openai/fixture-model" assert any(event.text == "SYNTHETIC_OPENHANDS_FOLLOWUP" for event in resumed.events) assert any(event.text == "SYNTHETIC_NATIVE_REPLY" for event in resumed.events) + native_env = { + **isolated_env(tmp_path), + "OPENHANDS_CONVERSATIONS_DIR": str(conversations), + "OPENHANDS_SUPPRESS_BANNER": "1", + "LLM_API_KEY": "synthetic-not-a-secret", + "LLM_BASE_URL": f"http://127.0.0.1:{port}/v1", + "LLM_MODEL": "openai/fixture-model", + } + native_tui_transcript( + [ + str(binary), + "--resume", + artifact.session_id, + "--override-with-envs", + "--exit-without-confirmation", + ], + cwd=work, + env=native_env, + markers=("Initialized conversation", artifact.session_id.replace("-", "")), + ) + viewed = subprocess.run( + [ + str(binary), + "view", + artifact.session_id.replace("-", ""), + "--limit", + "50", + ], + cwd=work, + env=native_env, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + assert viewed.returncode == 0, (viewed.stdout, viewed.stderr) + assert_request_markers( + [{"native_view": viewed.stdout}], + "SYNTHETIC_USER_MARKER", + "SYNTHETIC_TOOL_RESULT", + "SYNTHETIC_NATIVE_REPLY", + ) From be566907214aa5eec651655df6e13ecbf23e16ae Mon Sep 17 00:00:00 2001 From: xhluca Date: Wed, 26 Aug 2026 14:38:19 -0400 Subject: [PATCH 08/13] chore: prepare version 0.9.0 --- pyproject.toml | 7 +++++-- src/session_migrate/__init__.py | 2 +- src/session_migrate/formats/kilo.py | 4 +--- tests/test_cli.py | 2 +- uv.lock | 2 +- website/package-lock.json | 4 ++-- website/package.json | 3 +-- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 98e0f43..53ac2e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "session-migrate" -version = "0.8.0" -description = "Migrate native coding-agent sessions across twelve CLI harnesses" +version = "0.9.0" +description = "Migrate native coding-agent sessions across fifteen terminal harnesses" readme = "README.md" requires-python = ">=3.11" dependencies = [] @@ -22,6 +22,9 @@ keywords = [ "muse-code", "qwen-code", "kimi-code", + "grok", + "kilo-code", + "openhands", "vibe", "opencode", "pi", diff --git a/src/session_migrate/__init__.py b/src/session_migrate/__init__.py index 537a73c..3c5b517 100644 --- a/src/session_migrate/__init__.py +++ b/src/session_migrate/__init__.py @@ -1,3 +1,3 @@ """Native coding-agent session discovery and migration.""" -__version__ = "0.8.0" +__version__ = "0.9.0" diff --git a/src/session_migrate/formats/kilo.py b/src/session_migrate/formats/kilo.py index f5cc3c5..e567d43 100644 --- a/src/session_migrate/formats/kilo.py +++ b/src/session_migrate/formats/kilo.py @@ -17,9 +17,7 @@ PINNED_KILO_VERSION = "7.5.0" PINNED_KILO_LINUX_X64_BYTES = 145_118_408 -PINNED_KILO_LINUX_X64_SHA256 = ( - "ede061eb9178d0158ac66baa81619e2bf66859041d20d0a014798d38ddc7c1ce" -) +PINNED_KILO_LINUX_X64_SHA256 = "ede061eb9178d0158ac66baa81619e2bf66859041d20d0a014798d38ddc7c1ce" KILO_NATIVE_IMPORT_SUPPORTED = True MAX_NATIVE_BYTES = opencode.MAX_NATIVE_BYTES diff --git a/tests/test_cli.py b/tests/test_cli.py index a84f6c4..30fef77 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,7 +12,7 @@ def test_parser_exposes_version() -> None: - assert __version__ == "0.8.0" + assert __version__ == "0.9.0" assert build_parser().prog == "session-migrate" diff --git a/uv.lock b/uv.lock index f18a998..891bf6c 100644 --- a/uv.lock +++ b/uv.lock @@ -90,7 +90,7 @@ wheels = [ [[package]] name = "session-migrate" -version = "0.8.0" +version = "0.9.0" source = { editable = "." } [package.dev-dependencies] diff --git a/website/package-lock.json b/website/package-lock.json index 2aacf1a..54021f9 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -1,12 +1,12 @@ { "name": "session-migrate-website", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "session-migrate-website", - "version": "0.8.0", + "version": "0.9.0", "dependencies": { "react": "19.2.6", "react-dom": "19.2.6" diff --git a/website/package.json b/website/package.json index 64f78d8..42dd90e 100644 --- a/website/package.json +++ b/website/package.json @@ -1,6 +1,6 @@ { "name": "session-migrate-website", - "version": "0.8.0", + "version": "0.9.0", "private": true, "engines": { "node": ">=22.13.0" @@ -9,7 +9,6 @@ "dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev", "build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build", "start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start", - "render:og": "node scripts/render-og.mjs", "test": "npm run build && node --test tests/rendered-html.test.mjs", "lint": "eslint . --ignore-pattern dist --ignore-pattern .next" }, From 1fa0811f1d0138648ba08e80aba4aeb122c4f548 Mon Sep 17 00:00:00 2001 From: xhluca Date: Wed, 26 Aug 2026 14:38:54 -0400 Subject: [PATCH 09/13] docs: present fifteen supported agents --- CHANGELOG.md | 19 ++++ README.md | 45 +++++--- docs/additional-target-formats.md | 15 ++- docs/architecture.md | 49 ++++++--- docs/cli-reference.md | 56 ++++++---- docs/development.md | 11 +- docs/exploration-log.md | 44 ++++++++ docs/format-compatibility.md | 31 ++++-- docs/grok-kilo-openhands-formats.md | 158 +++++++++++++++++++++++++++ docs/session-catalog.md | 92 +++++++++++----- docs/specification.md | 28 +++-- docs/troubleshooting.md | 29 ++++- docs/validation-report.md | 46 +++++++- llms.txt | 2 +- website/app/CopyPrompt.tsx | 3 + website/app/globals.css | 19 ++-- website/app/layout.tsx | 6 +- website/app/page.tsx | 44 ++++---- website/public/llms.txt | 2 +- website/scripts/render-og.mjs | 18 --- website/tests/rendered-html.test.mjs | 11 +- 21 files changed, 558 insertions(+), 170 deletions(-) create mode 100644 docs/grok-kilo-openhands-formats.md delete mode 100644 website/scripts/render-og.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d1d7f88..ad82270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ here. Native format compatibility is documented separately in ## Unreleased +## 0.9.0 - 2026-08-26 + +- Add Grok 1.0.5, Kilo Code 7.5.0, and OpenHands 1.16.0 as readable, + writable, discoverable, inspectable, searchable native session formats. +- Expand the symmetric matrix from 144 to 225 ordered routes, including + same-format portable rewrites for all three formats. +- Preserve ordered text, linked tools/results, and supported images; preserve + OpenHands condensations and flatten Grok summaries with explicit loss + accounting. Keep private thinking and native runtime state out of migrated + history. +- Prove all three generated sessions through their exact pinned Linux x64 + binaries and a credential-free loopback model: each runtime loaded imported + context, continued the session, and retained the imported native prefix. +- Use only Kilo's official import/export commands and run import from the target + workspace to preserve its CWD. Work around Kilo 7.5.0's broken JSON list path + with a body-discarding official per-ID export collision probe. +- Add a website-hosted 15-agent logo grid to the README and landing page; no + third-party agent logo assets are added to the MIT Python repository. + - Add Oh My Pi (OMP) 18.0.5 as a readable, writable, discoverable, inspectable, searchable native session format, including title-based lookup and same-format portable rewrites. diff --git a/README.md b/README.md index 8b3af7b..9b4f113 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ GitHub Copilot CLI, Antigravity CLI, Cursor Agent, Mistral Vibe, Muse Code, Qwen Code, and - Kimi Code. + Kimi Code, Grok, + Kilo Code, and OpenHands.

## Install @@ -95,20 +96,31 @@ The linked procedure is sandbox-tested with both Claude Code and Codex. See the ## Compatibility -- Claude Code -- Codex CLI -- Pi -- Oh My Pi (OMP) -- OpenCode -- GitHub Copilot CLI -- Antigravity CLI -- Mistral Vibe -- Muse Code -- Qwen Code -- Kimi Code -- Cursor Agent (experimental, pinned, text only) - -Every listed format can be a source or target: 144 ordered routes, including + + + + + + + + + + + + + + + + + + + + + + +
Claude Code logo
Claude Code
Codex logo
Codex
Pi logo
Pi
Oh My Pi logo
Oh My Pi
OpenCode logo
OpenCode
Copilot logo
Copilot
Antigravity logo
Antigravity
Mistral Vibe logo
Mistral Vibe
Muse Code logo
Muse Code
Qwen Code logo
Qwen Code
Kimi Code logo
Kimi Code
Grok logo
Grok
Kilo Code logo
Kilo Code
OpenHands logo
OpenHands
Cursor logo
Cursor*
+ +Every listed format can be a source or target: 225 ordered routes, including same-format portable rewrites. Cursor deliberately transfers only ordered user/assistant text and is pinned to one exact Linux build; it is not a vendor-supported import API. Same-format migration creates a new independent @@ -123,7 +135,7 @@ session—it is not a byte-for-byte clone or a live sync. | Images | ✓ / partial | Supported image blocks move; other media is format-dependent | | Compaction summaries | ✓ / partial | Recreated where the target has a portable equivalent | | Readable reasoning | Vibe-only portable rewrite | Vibe keeps its explicit readable field when rewritten to Vibe; other/private/signed traces never move | -| Session name, ID, and picker entry | Recreated | The target gets a new native identity and resume state | +| Session name, ID, and picker entry | ✓ / partial | The target gets a new native identity; OpenHands derives its picker title from the first user turn | | Branches, forks, and subagents | Not flattened | Cataloged separately where detectable; migrate the parent session | | Private or signed thinking | No | Model/provider-bound traces are deliberately omitted | | Auth, hooks, policies, MCP, and runtime config | No | These remain with the source client | @@ -158,6 +170,7 @@ resumable native session. - [Experimental Cursor format](https://github.com/xhluca/session-migrate/blob/main/docs/cursor-format.md) - [Mistral Vibe format](https://github.com/xhluca/session-migrate/blob/main/docs/vibe-format.md) - [Muse, Qwen Code, and Kimi Code formats](https://github.com/xhluca/session-migrate/blob/main/docs/muse-qwen-kimi-formats.md) +- [Grok, Kilo Code, and OpenHands formats](https://github.com/xhluca/session-migrate/blob/main/docs/grok-kilo-openhands-formats.md) The Antigravity and Cursor adapters are clean-room, unofficial, and version-pinned. Their independently observed formats are published separately: diff --git a/docs/additional-target-formats.md b/docs/additional-target-formats.md index d8d86a6..01ac41a 100644 --- a/docs/additional-target-formats.md +++ b/docs/additional-target-formats.md @@ -1,8 +1,8 @@ # Additional native formats -This document summarizes the ten adapters beyond the original Claude/Codex +This document summarizes the thirteen adapters beyond the original Claude/Codex pair. All are readable sources, writable targets, searchable catalog formats, -and same-format portable-rewrite targets in `session-migrate` 0.8.0. +and same-format portable-rewrite targets in `session-migrate` 0.9.0. | Format | Pinned build | Native import strategy | Support level | | --- | --- | --- | --- | @@ -16,6 +16,9 @@ and same-format portable-rewrite targets in `session-migrate` 0.8.0. | Muse Code | `0.2.1` | Write durable native event stream | Stable pinned adapter | | Qwen Code | `0.22.1` | Write project-scoped chat graph JSONL | Stable pinned adapter | | Kimi Code | `0.38.0` | Write native state + main-agent wire journal | Stable pinned adapter | +| Grok | `1.0.5` | Write native ACP summary/update pair | Stable pinned adapter | +| Kilo Code | `7.5.0` | Official `export`/`import` CLI | Stable pinned adapter | +| OpenHands | `1.16.0` | Write native SDK event documents | Stable pinned adapter | “Stable” here means the exact pinned version passed the documented native oracle. It does not mean a vendor promises its private local format as an @@ -48,6 +51,14 @@ opt-in real OpenRouter continuation that required the native model to recall a marker found only in imported tool history. The default suite remains offline. See [Muse, Qwen Code, and Kimi Code formats](muse-qwen-kimi-formats.md). +## Grok, Kilo Code, and OpenHands + +Grok uses a paired summary/ACP update stream, Kilo uses its official +OpenCode-lineage import/export bundle, and OpenHands uses one validated SDK +event document per timeline item. Their exact binary pins, storage layouts, +loss accounting, and credential-free native continuation gates are documented +in [Grok, Kilo Code, and OpenHands formats](grok-kilo-openhands-formats.md). + ## Shared contract Every adapter: diff --git a/docs/architecture.md b/docs/architecture.md index d87a838..867b362 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,8 @@ conversation history, not an agent's entire runtime. ### JSON and JSONL sources -Claude, Codex, Pi, OMP, Copilot, Vibe, Muse, Qwen, and Kimi messages are bounded +Claude, Codex, Pi, OMP, Copilot, Vibe, Muse, Qwen, Kimi, Grok, and OpenHands +messages are bounded JSON/line streams. Readers cap total bytes, record bytes, record count, JSON nesting/nodes, and media payloads. They validate source identity before and after reading so an actively appending, @@ -62,13 +63,19 @@ replaced, or truncated file fails with a retryable error. - Qwen follows the active UUID/parent chat graph and counts inactive branches. - Kimi snapshots `state.json` and the main-agent protocol-`1.5` wire journal together before projecting context events. +- Grok snapshots `summary.json` and its ACP `updates.jsonl` together, validates + the encoded workspace/UUID linkage, and projects messages, images, and tools. +- OpenHands coherently snapshots its ordered event files plus optional bounded + complete SDK base state, validates the event union and action linkage, and + projects messages, actions/observations, images, and condensations. Its native + picker title is the bounded first user text. -### OpenCode virtual sources +### OpenCode and Kilo virtual sources -The catalog reads only native session metadata from `opencode.db`. A selected -source is exported through exact pinned `opencode export`, parsed as an official -bundle, and represented by the virtual path `opencode:`. The migrator never -queries message/part tables or writes OpenCode SQLite. +The catalog reads only native session metadata from `opencode.db` or `kilo.db`. +A selected source is exported through the exact pinned official CLI, parsed as +an official bundle, and represented by the virtual path `:`. The +migrator never queries message/part tables or writes either SQLite database. ### SQLite/protobuf sources @@ -102,6 +109,9 @@ loss_counters)`. No writer reads another source format directly. | Muse | Date-partitioned durable session event JSONL | | Qwen | Project-scoped append-only chat graph JSONL | | Kimi | Native `state.json` plus main-agent `wire.jsonl` session directory | +| Grok | Workspace-scoped `summary.json` plus ACP `updates.jsonl` | +| Kilo Code | Official JSON import bundle | +| OpenHands | Ordered SDK event JSON files in a conversation directory | Every generated artifact is reparsed/validated before publication. Target required IDs, timestamps, and metadata may be synthesized. Source tool output, @@ -131,15 +141,17 @@ silently changed. The source is never overwritten. - Claude/Codex/Pi/OMP write one native transcript and one manifest atomically. - Muse/Qwen write one native transcript and one manifest atomically. +- Grok publishes its paired summary/update files and manifest with rollback guards. +- OpenHands publishes the complete event-file directory and manifest with rollback guards. - Kimi reserves a native session directory and publishes its state, wire journal, and manifest with rollback guards. - Vibe reserves a short-ID-safe native directory and atomically publishes its metadata, message stream, and manifest. - Copilot reserves the complete session directory and writes events, workspace sidecar, and manifest. -- OpenCode reserves a private external manifest, invokes only the official - pinned importer, confirms the ID through official listing, then finalizes the - manifest. +- OpenCode and Kilo reserve a private external manifest, invoke only the + official pinned importer, confirm the ID through official read/export + operations, then finalize the manifest. - Antigravity and Cursor reserve the manifest, verify the exact pinned binary, invoke their clean-room atomic database installers, validate the installed session, then finalize the manifest. @@ -149,14 +161,15 @@ that the session may already exist. Blind retry is intentionally avoided. ## Version boundaries -Claude/Codex writers are pinned to the local integration image; Pi, OMP, OpenCode, -Copilot, Antigravity, Cursor, Vibe, Muse, Qwen, and Kimi to exact host +Claude/Codex writers are pinned to the local integration image; Pi, OMP, +OpenCode, Copilot, Antigravity, Cursor, Vibe, Muse, Qwen, Kimi, Grok, Kilo, and +OpenHands to exact host builds/releases. A source declaring a different version produces `unvalidated_source_version`. A `--target-cli-version` override changes metadata only and produces `unvalidated_target_version`; it never changes writer architecture. -Automatic OpenCode, Antigravity, and Cursor installation is stricter: metadata +Automatic OpenCode, Kilo, Antigravity, and Cursor installation is stricter: metadata overrides cannot bypass exact runtime version checks. Antigravity verifies its binary digest. Cursor verifies launcher, main bundle, protobuf-bearing chunk, bundled Node, sizes, SHA-256 values, and reported version. @@ -176,17 +189,19 @@ Enumeration covers: - Claude main sessions and nested sidechains; - Codex active and archived rollouts; - Pi and OMP workspace buckets, classified by their native heads; -- every OpenCode `session` row, including parents/archives; +- every OpenCode and Kilo `session` row, including parents/archives; - Copilot session directories, including missing event logs; -- Antigravity conversation DBs; and +- Antigravity conversation DBs; - Cursor workspace/chat DBs, including missing stores; - Vibe and Kimi multi-file session directories; -- Muse date-partitioned event streams; and -- Qwen project chat graphs. +- Muse date-partitioned event streams; +- Qwen project chat graphs; +- Grok workspace session directories; and +- OpenHands conversation event directories. JSONL rows use stat identity. Vibe and Kimi fingerprint both native files. Antigravity/Cursor include DB/WAL/SHM fingerprints. -OpenCode rows use a fingerprint of every indexed metadata field. Unavailable +OpenCode and Kilo rows use a fingerprint of every indexed metadata field. Unavailable roots retain prior rows instead of falsely marking everything missing. Search covers native names/titles and IDs. Paths/CWDs are opt-in. “All sessions” diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5dd31e5..26bdc7d 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,6 +1,6 @@ # CLI reference -This page documents `session-migrate` 0.8.0. `smigrate` is an exact shorthand +This page documents `session-migrate` 0.9.0. `smigrate` is an exact shorthand for the same executable. ## Commands @@ -22,10 +22,11 @@ session-migrate catalog show CATALOG_ID [--include-paths] [--json] `FORMAT` and `TARGET` accept: ```text -claude codex pi omp opencode copilot antigravity cursor vibe muse qwen kimi +claude codex pi omp opencode copilot antigravity cursor +vibe muse qwen kimi grok kilo openhands ``` -All twelve formats are readable and writable. Cursor is an experimental, +All fifteen formats are readable and writable. Cursor is an experimental, text-only adapter pinned to one exact Cursor Agent build. Same-format migration is supported as a portable rewrite into a new independent session. @@ -47,6 +48,11 @@ validation. `--format` bypasses automatic format selection. It does not make an unsupported schema or version safe. +Kilo and OpenCode official bundles intentionally share one schema and retain +stored metadata across cross-imports. A standalone bundle cannot be attributed +reliably, so automatic inspection fails closed; pass `--format kilo` or +`--format opencode` for a trusted bundle. + ## `convert` `convert` writes a standalone target artifact plus a sidecar manifest: @@ -56,10 +62,11 @@ smigrate convert SOURCE --to codex --output ./rollout.jsonl ``` The manifest is `OUTPUT.session-migrate.json`. `convert` never installs into a -native agent home and never invokes a target CLI. For OpenCode it writes an -official import bundle; for Antigravity and Cursor it writes a complete SQLite -database; for Vibe it writes a validation bundle that `import` publishes as -native `meta.json` plus `messages.jsonl`. +native agent home and never invokes a target CLI. For OpenCode and Kilo Code it +writes an official import bundle; for Antigravity and Cursor it writes a +complete SQLite database. Multi-file targets such as Vibe, Kimi, Grok, and +OpenHands use a validation bundle that `import` publishes into their complete +native layout. ## `import` @@ -75,11 +82,11 @@ performs conversion, native validation, and collision checks but does not install a session or manifest. OpenCode's official read/list preflight can initialize its ordinary XDG cache/database metadata during a dry run. -OpenCode import always uses the official pinned CLI and does not accept -`--home`; isolate or select it with normal `HOME`/XDG variables. Antigravity and -Cursor installs verify the exact pinned executable and its published hashes. -Muse and Qwen install one native JSONL; Kimi installs its native `state.json` -and main-agent `wire.jsonl` together. +OpenCode and Kilo import always use their official pinned CLIs and do not accept +`--home`; isolate or select them with normal `HOME`/XDG variables. Antigravity +and Cursor installs verify the exact pinned executable and its published +hashes. Muse and Qwen install one native JSONL. Kimi, Grok, and OpenHands +publish their validated multi-file native sessions together. ## `transfer` @@ -93,11 +100,15 @@ smigrate transfer SOURCE_UUID --from vibe --source-cwd "$PWD" --to codex smigrate transfer SOURCE_UUID --from qwen --source-cwd "$PWD" --to kimi smigrate transfer session_SOURCE_UUID --from kimi --source-cwd "$PWD" --to muse smigrate transfer ses_... --from opencode --to pi --source-cli ~/.opencode/bin/opencode +smigrate transfer SESSION_UUID --from grok --source-cwd "$PWD" --to openhands +smigrate transfer ses_... --from kilo --to claude --source-cli /path/to/kilo +smigrate transfer SESSION_UUID --from openhands --to qwen ``` -Claude, Pi, OMP, Cursor, Vibe, Qwen, and Kimi can use `--source-cwd` to select a -workspace-specific store. OpenCode is virtual: the pinned official CLI exports -the requested ID. All other sources are read from their native files. +Claude, Pi, OMP, Cursor, Vibe, Qwen, Kimi, and Grok can use `--source-cwd` to +select a workspace-specific store. OpenCode and Kilo are virtual: the pinned +official CLI exports the requested ID. All other sources are read from their +native files. Catalog transfer avoids ambiguous paths and duplicate UUIDs: @@ -126,16 +137,16 @@ default. Every other source requires an explicit target. | `--session-id UUID` | Assign a new target UUID; generated by default | | `--cwd PATH` | Target working directory; precedence is option, source CWD, process CWD | | `--target-cli-version VERSION` | Change emitted metadata only; the writer architecture remains pinned | -| `--target-cli PATH` | Pinned OpenCode, Antigravity, or Cursor executable for native import | +| `--target-cli PATH` | Pinned OpenCode, Kilo, Antigravity, or Cursor executable for native import | | `--model-provider ID` | Codex, Pi, OMP, OpenCode, or Muse target provider | -| `--model ID` | Claude, Pi, OMP, OpenCode, Copilot, Antigravity, Vibe, Muse, Qwen, or Kimi target model label | -| `--home PATH` | Target native home, except OpenCode | +| `--model ID` | Target model label for formats that persist one, including Grok, Kilo, and OpenHands | +| `--home PATH` | Target native home, except OpenCode and Kilo | | `--dry-run` | Validate and collision-check without installing migrator artifacts | An irrelevant target-specific option may be accepted but has no effect. The manifest records the target metadata version and warns when it differs from the -validated writer pin. Automatic OpenCode, Antigravity, and Cursor installation -still requires the exact pinned version. +validated writer pin. Automatic OpenCode, Kilo, Antigravity, and Cursor +installation still requires the exact pinned version. ## Home resolution @@ -153,6 +164,9 @@ still requires the exact pinned version. | Muse | `$XDG_DATA_HOME/muse`, otherwise `~/.local/share/muse` | | Qwen | `$QWEN_HOME`, otherwise `~/.qwen` | | Kimi | `$KIMI_CODE_HOME`, otherwise `~/.kimi-code` | +| Grok | `$GROK_HOME`, otherwise `~/.grok` | +| Kilo Code | official CLI under its normal XDG data root | +| OpenHands | `$OPENHANDS_CONVERSATIONS_DIR`, otherwise `~/.openhands/conversations` | Explicit `--home` or `--source-home` wins where supported. All CLI path options expand `~` consistently. @@ -182,6 +196,8 @@ Additional roots are repeatable: --cursor-root PATH --vibe-root PATH --muse-root PATH --qwen-root PATH --kimi-root PATH +--grok-root PATH --kilo-root PATH +--openhands-root PATH ``` `--discover-under` is bounded to the supplied directory, never follows diff --git a/docs/development.md b/docs/development.md index 53d7bee..1f69410 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,6 +36,9 @@ pytest and Ruff are development dependencies locked by `uv.lock`. | `src/session_migrate/formats/muse.py` | Muse Code 0.2.1 durable event adapter | | `src/session_migrate/formats/qwen.py` | Qwen Code 0.22.1 chat-graph adapter | | `src/session_migrate/formats/kimi.py` | Kimi Code 0.38.0 state/wire adapter | +| `src/session_migrate/formats/grok.py` | Grok 1.0.5 ACP-update adapter | +| `src/session_migrate/formats/kilo.py` | Kilo Code 7.5.0 official-bundle adapter | +| `src/session_migrate/formats/openhands.py` | OpenHands 1.16.0 SDK-event adapter | | `src/session_migrate/formats/common.py` | Shared timestamps, text, and image validation | | `src/session_migrate/conversion.py` | Mapping orchestration, manifests, and installation | | `tests/fixtures/` | Synthetic, credential-free pinned-version transcripts | @@ -76,7 +79,7 @@ The Docker check is credential-free and network-disabled. It must prove the target selected the imported UUID, preserved the imported prefix, and appended to the same file. A provider response is not required. -Pi/OMP/OpenCode/Copilot/Antigravity/Cursor/Vibe/Muse/Qwen/Kimi adapter changes +Pi/OMP/OpenCode/Copilot/Antigravity/Cursor/Vibe/Muse/Qwen/Kimi/Grok/Kilo/OpenHands adapter changes additionally require the exact pinned binaries when available: ```console @@ -94,6 +97,10 @@ SESSION_MIGRATE_KIMI_BIN=/path/to/kimi-0.38.0 \ SESSION_MIGRATE_MUSE_BIN=/path/to/muse-0.2.1 \ SESSION_MIGRATE_MUSE_OPENROUTER_BIN=/path/to/muse-openrouter-0.3.2 \ uv run pytest -q tests/test_muse_qwen_kimi_native.py +SESSION_MIGRATE_GROK_BIN=/path/to/grok-1.0.5 \ +SESSION_MIGRATE_KILO_BIN=/path/to/kilo-7.5.0 \ +SESSION_MIGRATE_OPENHANDS_BIN=/path/to/openhands-1.16.0 \ + uv run pytest -q tests/test_grok_kilo_openhands_native.py uv run python scripts/validate-muse-qwen-kimi-corpus.py \ --claude-root /private/claude-home uv run python scripts/validate-muse-qwen-kimi-corpus.py \ @@ -134,7 +141,7 @@ The Pi-specific harness may translate the current Codex OAuth record only into a disposable, mode-`0600` isolated Pi auth file, never a normal Pi home. Never log credentials or make credential transfer part of the migrator itself. -The source-matrix gate is symmetric: every readable source exercises all twelve +The source-matrix gate is symmetric: every readable source exercises all fifteen targets, including same-format portable rewrites. Cursor comparisons project only ordered text and independently verify every loss counter. Antigravity and Cursor require their exact clean-room native oracles; Cursor remains labeled diff --git a/docs/exploration-log.md b/docs/exploration-log.md index 3c3e1f3..b468bbe 100644 --- a/docs/exploration-log.md +++ b/docs/exploration-log.md @@ -599,3 +599,47 @@ prompt to the model request, appended the provider reply to the same native journal, and rewrote only its fixed title slot during a native rename. The complete mapping and binary identity are recorded in [Oh My Pi session format](omp-format.md). + +## 2026-08-26: Grok, Kilo Code, and OpenHands + +Exact Linux x64 artifacts were inspected and pinned before implementation: +Grok Build 1.0.5 (`5115b46bc9`), Kilo Code 7.5.0, and OpenHands CLI 1.16.0 +with SDK 1.21.0. Research and native gates ran in private disposable homes; +only sanitized fixtures, structural observations, and content-free hashes were +retained. + +Grok stores a percent-encoded CWD directory containing `summary.json` and an +ACP `updates.jsonl`. Both public `session/update` and its `_x.ai` namespaced +variant occur in 1.0.5. The summary count is an exact update-count invariant. +The final reader snapshots both files before either read and verifies identity, +size, mtime, and ctime again after both reads so a live append or replacement +cannot create a hybrid source. + +Kilo is an OpenCode-lineage runtime. Its official JSON import/export shape is +schema-identical to OpenCode, including when a bundle crosses between the two +clients; standalone auto-detection must therefore be ambiguous rather than +guessing from a version field. Kilo 7.5.0's importer also replaces bundle CWD +with its process CWD, and its JSON session-list path crashes on a valid imported +row lacking `time.updated`. The implementation imports from the requested +workspace and uses a body-discarding official per-ID export as its collision +probe instead of touching the SQLite database. + +OpenHands stores one SDK event JSON document per ordinal below a conversation +directory, plus optional derived picker/runtime state. Native loads exposed two +non-obvious required fields: generated action and condensation events need +`llm_response_id`, and observations must link to an existing action. Its picker +title follows the first user prompt when no explicit derived title exists. The +reader uses a bounded coherent inventory. A partial `base_state.json` triggers +the SDK's strict restore path and is unsafe to invent, so the writer emits only +authoritative events; the pinned SDK rebuilds a complete runtime snapshot on +first resume. + +All three exact runtimes received an imported prefix and a new prompt through a +loopback model. Each model request contained markers that existed only in the +imported history, each runtime appended a native continuation, and the normal +adapter reparsed the result. A second PTY gate opened the actual Grok fullscreen +and Kilo mini TUIs and found the shared imported/continued text on screen. +OpenHands' interactive TUI opened the imported conversation, while its native +`view` surface displayed the full imported and continued trajectory. The exact +paths, byte sizes, SHA-256 pins, mapping rules, and opt-in commands are recorded in +[Grok, Kilo Code, and OpenHands formats](grok-kilo-openhands-formats.md). diff --git a/docs/format-compatibility.md b/docs/format-compatibility.md index f916045..6bc1a79 100644 --- a/docs/format-compatibility.md +++ b/docs/format-compatibility.md @@ -28,6 +28,9 @@ to separately installed host binaries: | Muse Code source and target | `0.2.1 (0.2.1-R1215.1)` | | Qwen Code source and target | `0.22.1` | | Kimi Code source and target | `0.38.0` | +| Grok source and target | `1.0.5` | +| Kilo Code source and target | `7.5.0` | +| OpenHands source and target | `1.16.0` (SDK `1.21.0`) | Claude Code `2.1.234` and Codex CLI `0.147.0` were also inspected on the host. The Codex `rust-v0.147.0` source was used to understand rollout discovery and @@ -47,16 +50,18 @@ network access failed. This proves discovery, parsing, selection, and append compatibility. It does not claim that an unauthenticated model turn completed. Muse, Qwen, and Kimi additionally passed explicit opt-in OpenRouter continuations that verified imported model-visible history; those tests are isolated and -skipped by the default suite. +skipped by the default suite. Grok, Kilo, and OpenHands passed credential-free +exact-binary continuations through a loopback OpenAI-compatible fixture. -All twelve formats are sources and targets. Their mappings, native probes, and +All fifteen formats are sources and targets. Their mappings, native probes, and loss keys are specified in [Additional native formats](additional-target-formats.md), [OpenCode source research](opencode-source-exploration.md), [Copilot source research](copilot-source-format.md), [Antigravity](antigravity-format.md), [Cursor](cursor-format.md), [Oh My Pi](omp-format.md), [Mistral Vibe](vibe-format.md), and -[Muse/Qwen/Kimi](muse-qwen-kimi-formats.md). Cursor is +[Muse/Qwen/Kimi](muse-qwen-kimi-formats.md), and +[Grok/Kilo/OpenHands](grok-kilo-openhands-formats.md). Cursor is the exception to the broad portable feature set: its experimental adapter moves ordered user/assistant text only and counts every omitted class. @@ -164,7 +169,8 @@ the current fixed-title-slot form. See [the exact OMP contract](omp-format.md). - Kimi uses `$KIMI_CODE_HOME/sessions//session_/state.json` plus `agents/main/wire.jsonl`; both files are snapshotted and validated as one native session. -OMP, OpenCode, Copilot, Vibe, Muse, Qwen, and Kimi have first-class source readers. Antigravity and +OMP, OpenCode, Copilot, Vibe, Muse, Qwen, Kimi, Grok, Kilo, and OpenHands have +first-class source readers. Antigravity and Cursor SQLite readers take consistent snapshots that include committed WAL state. Vibe and Kimi snapshot their multi-file sessions and fail if any member changes. @@ -283,10 +289,10 @@ fork-related state. Those records are not all portable conversation history. ## Route support -Every ordered pair among the twelve formats is implemented, for 144 routes: +Every ordered pair among the fifteen formats is implemented, for 225 routes: - full portable adapters: Claude, Codex legacy, Pi, OMP, OpenCode, Copilot, - Antigravity, Vibe, Muse, Qwen, and Kimi; + Antigravity, Vibe, Muse, Qwen, Kimi, Grok, Kilo, and OpenHands; - experimental text-only adapter: Cursor. Same-format routes are portable rewrites into new sessions, not byte copies. @@ -294,7 +300,8 @@ Codex paginated/history-base sources remain fail-closed. Cursor is experimental, build-pinned, and deliberately transfers only ordered user/assistant text. The detailed table below explains the original Claude/Codex pair; target-specific behavior is documented in [Additional native formats](additional-target-formats.md) -and [Muse/Qwen/Kimi](muse-qwen-kimi-formats.md). +and [Muse/Qwen/Kimi](muse-qwen-kimi-formats.md). Grok, Kilo, and OpenHands +details are in [their native format note](grok-kilo-openhands-formats.md). Legend: @@ -351,13 +358,13 @@ Imports never mutate the source or intentionally overwrite an existing target. I bounded at 64 MiB per record, 256 MiB per file, and 100,000 records by default. Device/inode/size/modification metadata is checked across the read so an actively appending or replaced source fails for a clean retry. -Claude, Codex, Pi, OMP, Copilot, Antigravity, Cursor, Vibe, Muse, Qwen, and Kimi -native files plus +Claude, Codex, Pi, OMP, Copilot, Antigravity, Cursor, Vibe, Muse, Qwen, Kimi, +Grok, and OpenHands native files plus content-free manifests use no-clobber private publication; if manifest creation fails after a new filesystem target is created, the error reports whether that -native session may remain. OpenCode instead uses the exact pinned public -importer and publishes only a private migrator manifest after official -list-based verification; the migrator never writes its SQLite. +native session may remain. OpenCode and Kilo instead use their exact pinned +public importers and publish only a private migrator manifest after official +export-based verification; the migrator never writes either SQLite database. Explicit UUID resume is the authoritative integration check; picker ordering and previews can vary by CLI version and current working directory. diff --git a/docs/grok-kilo-openhands-formats.md b/docs/grok-kilo-openhands-formats.md new file mode 100644 index 0000000..53d1264 --- /dev/null +++ b/docs/grok-kilo-openhands-formats.md @@ -0,0 +1,158 @@ +# Grok, Kilo Code, and OpenHands formats + +This note records the exact native contracts used by the three adapters added +in `session-migrate` 0.9.0. These are versioned implementation observations, +not promises that future releases will keep the same storage layout. + +## Pinned releases + +| Harness | Validated release | Exact Linux x64 artifact | +| --- | --- | --- | +| Grok | `xai-org/grok-build 1.0.5` (`5115b46bc9`) | 166,854,368 bytes; SHA-256 `9ba87444e1819e8f6104adbbf4676a870c204380aa5c3e1c38a926c4ea677238` | +| Kilo Code | `Kilo-Org/kilocode 7.5.0` | 145,118,408 bytes; SHA-256 `ede061eb9178d0158ac66baa81619e2bf66859041d20d0a014798d38ddc7c1ce` | +| OpenHands | `OpenHands-CLI 1.16.0`, SDK `1.21.0` | 88,139,576 bytes; SHA-256 `cb04ee2da91c698733d5201c55cbc08d81dccc9d64b666275abf68a4e0c590e3` | + +The checked-in default tests use sanitized fixtures and a local HTTP fixture; +they need no provider account or API key. The opt-in native tests verify these +exact artifacts before execution and use a loopback OpenAI-compatible server. + +## Grok + +Grok stores a session below a percent-encoded working-directory bucket: + +```text +$GROK_HOME/sessions/// +├── summary.json +└── updates.jsonl +``` + +`GROK_HOME` defaults to `~/.grok`. The update log is the authoritative ACP +timeline. The reader accepts both the public `session/update` method and the +`_x.ai/session/update` method emitted by Grok 1.0.5. It validates one UUID, +integer timestamps, known message/image/tool shapes, bounded record counts, and +stable paired-file identity. + +User/assistant text, user images, linked tool calls/results, and portable +summaries are mapped. A summary is flattened into an explicitly marked native +user chunk because this Grok build has no equivalent portable compaction item. +Private thought chunks, provider payloads, namespaces, unsupported result +blocks, lifecycle updates, and runtime-only state are counted in the manifest. + +The native gate installed a generated session, resumed it by UUID with the +exact `grok` binary, sent the imported prefix plus a follow-up to the local +fixture model, received a native assistant reply, and proved that Grok appended +to the original `updates.jsonl` prefix. + +## Kilo Code + +Kilo 7.5.0 shares OpenCode's official import/export bundle shape and stores its +runtime state in its own SQLite database under the normal XDG data root. The +migrator never writes that database. It uses only: + +```text +kilo import --pure +kilo export --pure +kilo run ... --session --pure +``` + +The bundle reader/writer validates the Kilo schema, IDs, timestamp order, +message/part ownership, tool linkage, supported images, compaction parts, and +bounded JSON depth/counts. Kilo-only runtime, patch, snapshot, permissions, +reasoning signatures, and unknown parts remain reason-specific losses. + +Kilo and OpenCode intentionally share the same import/export schema, and a +cross-imported bundle retains its stored metadata. A standalone bundle therefore +has no reliable producer marker: file-based inspection fails closed and asks for +`--format kilo` or `--format opencode`. Native-ID transfer remains unambiguous +because `--from` selects the official exporter. + +Two native behaviors matter: + +- the importer replaces the bundle CWD with its process CWD, so the migrator + invokes it from the requested target workspace and verifies the exported CWD; +- `kilo session list --all --format json` crashes in 7.5.0 for a valid imported + session when `time.updated` is absent. Collision checks therefore use the + content-free official `kilo export ` probe, with exported bodies discarded. + +The exact native gate imports the generated bundle, continues it through a +loopback model, verifies the imported messages/tool result reached that model, +and reparses the official export after the appended reply. + +## OpenHands + +OpenHands keeps one conversation directory per UUID: + +```text +$OPENHANDS_CONVERSATIONS_DIR// +├── base_state.json # optional complete SDK runtime snapshot +└── events/ + └── event--.json +``` + +The default is `~/.openhands/conversations`. Each generated event is a separate +JSON document using the SDK's event union. The reader validates the filename +ordinal/UUID, event ID/session ID, timestamps, known content blocks, tool IDs, +and unique event/action linkage. A bounded `base_state.json`, when present, +contributes model and workspace metadata and participates in coherent snapshot +checks; the event stream remains authoritative. + +User/assistant text, user images, linked actions/observations, result images, +and condensation summaries are portable. Generated `ActionEvent` and +`Condensation` records include the required `llm_response_id`; omitting it +produces a file that looks plausible but the pinned SDK refuses to load. Agent +state, metrics, delegates, private reasoning, MCP/runtime configuration, and +unsupported event variants are counted rather than replayed. + +The generated bundle retains requested CWD, model, title, and CLI version as +validated migration metadata, but installation intentionally writes only event +documents. A partial base state makes SDK 1.21.0 enter a strict restore path and +is not safe to fabricate. On first resume, the SDK rebuilds the complete base +state from the launch workspace and active model configuration; the native gate +checks that result. The native picker title is derived from the first user text, +matching this pinned release. + +The native gate loads the generated event directory with OpenHands 1.16.0, +sends the imported messages and tool result to the local fixture model, appends +a user and assistant turn, and proves every imported event file stayed +byte-for-byte unchanged. + +## CLI examples + +```bash +smigrate catalog refresh +smigrate catalog search "parser timeout" --format grok + +smigrate transfer --title "parser timeout" --from grok --to openhands --dry-run +smigrate transfer --title "parser timeout" --from grok --to openhands + +smigrate transfer SESSION_ID --from openhands --to kilo \ + --target-cli /path/to/kilo-7.5.0 +``` + +Kilo is a virtual source/target: native lookup and installation require the +pinned official binary and do not accept `--home`. Grok and OpenHands accept +`--source-home`/`--home`; their defaults and environment variables are listed +in the [CLI reference](cli-reference.md). + +## Test contract + +The default suite covers sanitized fixture parsing, malformed-input rejection, +serialization/reparse equivalence, all 225 ordered routes, installation +collision handling, catalog indexing/search, inspect output, and exact manifest +loss counters. + +The opt-in exact-binary gate is: + +```bash +SESSION_MIGRATE_GROK_BIN=/path/to/grok-1.0.5 \ +SESSION_MIGRATE_KILO_BIN=/path/to/kilo-7.5.0 \ +SESSION_MIGRATE_OPENHANDS_BIN=/path/to/openhands-1.16.0 \ + uv run pytest -q tests/test_grok_kilo_openhands_native.py +``` + +It is credential-free and binds its fixture server only to loopback. It does +not read or copy any native authentication store. After each model-visible +continuation, it launches the actual interactive terminal surface in a bounded +PTY: Grok's fullscreen TUI and Kilo's mini TUI must render imported history; +OpenHands' TUI must open the imported conversation and its native `view` +command must render the complete imported/continued trajectory. diff --git a/docs/session-catalog.md b/docs/session-catalog.md index 0be251e..be67db4 100644 --- a/docs/session-catalog.md +++ b/docs/session-catalog.md @@ -2,9 +2,9 @@ The catalog finds and searches native Claude Code, Codex CLI, Pi, Oh My Pi, OpenCode, GitHub Copilot CLI, Antigravity CLI, Cursor Agent, Mistral Vibe, Muse Code, -Qwen Code, and Kimi Code sessions +Qwen Code, Kimi Code, Grok, Kilo Code, and OpenHands sessions across more than one agent home. Native JSON/JSONL or per-session SQLite stores -remain authoritative; OpenCode's read-only `session` table is its inventory. The catalog is a private, +remain authoritative; OpenCode and Kilo read-only `session` tables are their inventories. The catalog is a private, disposable SQLite index and never changes an agent session store. ## What “all sessions” means @@ -12,28 +12,32 @@ disposable SQLite index and never changes an agent session store. An exhaustive refresh means **every recognized native session below every enabled catalog root**: every expected JSONL or per-session database, including declared Copilot/Cursor directories with missing native state, plus every -OpenCode `session` row. It does not mean an implicit whole-disk crawl. Agent +OpenCode and Kilo `session` row. It does not mean an implicit whole-disk crawl. Agent homes can have arbitrary names and locations, so discovering all of them still requires either a known root or an explicit search boundary. The catalog adds these roots automatically when they exist: - `~/.claude`, `~/.codex`, `~/.pi/agent`, `~/.omp/agent`, `~/.copilot`, - `~/.gemini/antigravity-cli`, `~/.vibe`, `~/.qwen`, `~/.kimi-code`, Muse's - resolved XDG data home, and Cursor's resolved config home; -- `$XDG_DATA_HOME/opencode`, or `~/.local/share/opencode` when `XDG_DATA_HOME` - is unset; + `~/.gemini/antigravity-cli`, `~/.vibe`, `~/.qwen`, `~/.kimi-code`, `~/.grok`, + `~/.openhands/conversations`, Muse's resolved XDG data home, and Cursor's + resolved config home; +- `$XDG_DATA_HOME/opencode` and `$XDG_DATA_HOME/kilo`, or their + `~/.local/share` fallbacks when `XDG_DATA_HOME` is unset; - `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `PI_CODING_AGENT_DIR`, `COPILOT_HOME`, - `CURSOR_CONFIG_DIR`, `VIBE_HOME`, `QWEN_HOME`, `KIMI_CODE_HOME`, and the + `CURSOR_CONFIG_DIR`, `VIBE_HOME`, `QWEN_HOME`, `KIMI_CODE_HOME`, `GROK_HOME`, + `OPENHANDS_CONVERSATIONS_DIR`, and the XDG fallbacks used by Cursor and Muse; and - `.claude`, `.codex`, `.pi/agent`, `.omp/agent`, `.copilot`, `.gemini/antigravity-cli`, - `.cursor`, `.vibe`, `.qwen`, or `.kimi-code` native homes in the current + `.cursor`, `.vibe`, `.qwen`, `.kimi-code`, `.grok`, or `.openhands/conversations` + native homes in the current directory or one of its ancestors. Use the repeatable `--claude-root`, `--codex-root`, `--pi-root`, `--omp-root`, `--opencode-root`, `--copilot-root`, `--antigravity-root`, `--cursor-root`, -`--vibe-root`, `--muse-root`, `--qwen-root`, or `--kimi-root` +`--vibe-root`, `--muse-root`, `--qwen-root`, `--kimi-root`, `--grok-root`, +`--kilo-root`, or `--openhands-root` option for arbitrary custom homes. These roots persist for later refreshes. Use repeatable `--discover-under DIRECTORY` to find project-local homes below a specific workspace. Discovery does not follow directory symlinks, stops @@ -124,6 +128,22 @@ HOME/sessions//session_/ └── agents/main/wire.jsonl ``` +Grok enumeration covers paired summary and ACP update streams below its +working-directory buckets: + +```text +HOME/sessions/// +├── summary.json +└── updates.jsonl +``` + +OpenHands enumeration covers every UUID conversation directory containing an +event stream: + +```text +HOME//events/event--.json +``` + OpenCode enumeration opens `HOME/opencode.db` with SQLite `mode=ro` and `query_only`, then projects only these `session` columns: @@ -132,9 +152,10 @@ id, title, directory, version, time_created, time_updated, parent_id, time_archived ``` -It does not run `opencode export` per row or inspect `message`/`part` tables. -This keeps a 70,000-session refresh proportional to the small inventory table, -not the total transcript corpus. +Kilo uses the same bounded read-only inventory strategy against `HOME/kilo.db`. +Neither scan runs a per-row export or inspects `message`/`part` tables. +This keeps refresh work proportional to the small inventory table, not the +total transcript corpus. Consequently, archived sessions, duplicate UUIDs, nested sidechains/subagents, malformed files, and absent Copilot/Cursor native stores remain discoverable. Claude @@ -171,7 +192,10 @@ session-migrate catalog refresh \ --vibe-root /agent-homes/vibe \ --muse-root /agent-homes/muse \ --qwen-root /agent-homes/qwen \ - --kimi-root /agent-homes/kimi + --kimi-root /agent-homes/kimi \ + --grok-root /agent-homes/grok \ + --kilo-root /agent-homes/kilo \ + --openhands-root /agent-homes/openhands # Find project-local homes within an explicit workspace boundary. session-migrate catalog refresh --discover-under /workspaces @@ -190,9 +214,10 @@ session-migrate transfer --title "investigation release" --from claude --to pi `catalog list`, `catalog search`, and `catalog show` expose an opaque `catalog_id`. It selects one physical JSONL even when several roots contain the -same native UUID. For OpenCode it selects a virtual `(root, native session ID)` -reference instead of pretending `opencode.db` is an export bundle. Transfer -then invokes the official OpenCode exporter for that one ID. File-based sources +same native UUID. For OpenCode and Kilo it selects a virtual +`(root, native session ID)` reference instead of pretending a SQLite database +is an export bundle. Transfer then invokes the corresponding official exporter +for that one ID. File-based sources are reopened and authoritatively parsed before conversion; an index status never bypasses normal conversion validation. @@ -205,12 +230,13 @@ session-migrate catalog refresh [--opencode-root HOME]... [--copilot-root HOME]... [--antigravity-root HOME]... [--cursor-root HOME]... [--vibe-root HOME]... [--muse-root HOME]... [--qwen-root HOME]... - [--kimi-root HOME]... + [--kimi-root HOME]... [--grok-root HOME]... [--kilo-root HOME]... + [--openhands-root HOME]... [--discover-under DIRECTORY]... [--no-auto-roots] [--validate] [--json] session-migrate catalog roots list [--json] session-migrate catalog roots add PATH - --format claude|codex|pi|omp|opencode|copilot|antigravity|cursor|vibe|muse|qwen|kimi [--json] + --format claude|codex|pi|omp|opencode|copilot|antigravity|cursor|vibe|muse|qwen|kimi|grok|kilo|openhands [--json] session-migrate catalog roots remove ROOT_ID session-migrate catalog list [FILTERS] [--json] @@ -234,7 +260,7 @@ match at least one indexed field for the same session. Search covers: - Claude sidechain `agentId` and `agent-` filename keys; - Pi `session_info.name` values and native session IDs; - OMP fixed-slot/header/`title_change` values and native session IDs; -- OpenCode native session IDs and bounded `session.title` values; +- OpenCode and Kilo native session IDs and bounded `session.title` values; - Copilot session IDs, `session.title_changed` values, and bounded picker names from `workspace.yaml`; - Antigravity UUIDs and bounded native summary titles; @@ -242,7 +268,9 @@ match at least one indexed field for the same session. Search covers: - Vibe UUIDs and bounded `meta.json` titles; - Muse UUIDs; - Qwen UUIDs and native custom titles; and -- Kimi native/portable UUIDs and bounded `state.json` titles. +- Kimi native/portable UUIDs and bounded `state.json` titles; +- Grok UUIDs and bounded native summary titles; and +- OpenHands UUIDs and their bounded native picker titles. Each stored native label is bounded to 512 Unicode code points. This prevents a vendor field containing an unexpectedly long prompt-like title from making the @@ -263,7 +291,7 @@ registered roots. | Status | Meaning | | --- | --- | -| `candidate` | Fast structural metadata scan passed; full conversion has not been requested. OpenCode rows remain candidates until their one-session official export is parsed. | +| `candidate` | Fast structural metadata scan passed; full conversion has not been requested. OpenCode and Kilo rows remain candidates until their one-session official export is parsed. | | `validated` | The exact stat identity was fully parsed, dry-converted, and target-validated during `refresh --validate`. | | `unsupported` | The file is a recognized session type intentionally rejected by conversion, such as a Claude sidechain or Codex paginated/history-base rollout. | | `corrupt` | JSONL, SQLite/protobuf, native structure, or explicit conversion validation failed. | @@ -287,10 +315,13 @@ guess. JSONL refresh compares device, inode, byte size, and nanosecond modification time. Vibe fingerprints both `meta.json` and `messages.jsonl`; Kimi fingerprints -both `state.json` and `wire.jsonl`. Antigravity and Cursor additionally +both `state.json` and `wire.jsonl`; Grok fingerprints `summary.json` and +`updates.jsonl`; and OpenHands fingerprints its bounded event-file inventory. +OpenHands also includes optional bounded `base_state.json` identity because it +contributes workspace/model metadata. Antigravity and Cursor additionally fingerprint the main database plus WAL/SHM identities so committed live state -invalidates the row. OpenCode -refresh fingerprints every indexed metadata field per session, +invalidates the row. The OpenCode and Kilo refresh paths fingerprint each +indexed metadata field per session, so a title, parent, archive state, version, CWD, or timestamp change is detected even if a third-party writer fails to advance `time_updated`. An unchanged source reuses its structural result. The JSONL scanner checks the source @@ -303,16 +334,17 @@ late in a transcript. It does not materialize message bodies, but an initial refresh still performs I/O proportional to the total bytes in all configured session stores. Native Codex SQLite is used only to add `name`, `title`, and spawn-lineage metadata; it is not trusted as inventory because it can omit -rollout files. OpenCode SQLite is authoritative for OpenCode because the -official CLI itself lists and exports sessions from that store. If a native -database is temporarily absent, locked, has an unsupported schema, or is +rollout files. OpenCode and Kilo SQLite are authoritative for their respective +inventories because the official CLIs list and export sessions from those +stores. If a native database is temporarily absent, locked, has an unsupported +schema, or is replaced by a symlink, the root scan fails closed and its previous rows remain intact. `--validate` is deliberately explicit. For every changed file/database `candidate`, it runs the same bounded source adapter and target conversion -validation used by the normal migrator. OpenCode inventory refresh never -exports tens of thousands of bundles merely to validate them; transfer exports +validation used by the normal migrator. OpenCode and Kilo inventory refresh +never exports every bundle merely to validate it; transfer exports and validates the selected native ID. A later file change clears a validation guarantee. Transfer always performs an authoritative load regardless of catalog status. diff --git a/docs/specification.md b/docs/specification.md index 30502b3..4e9a391 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -1,6 +1,6 @@ # Session migration specification -This is the user-facing contract for `session-migrate` 0.8.0. +This is the user-facing contract for `session-migrate` 0.9.0. ## Scope @@ -18,6 +18,9 @@ The migrator reads and writes native sessions for: - Muse Code 0.2.1 - Qwen Code 0.22.1 - Kimi Code 0.38.0 +- Grok 1.0.5 +- Kilo Code 7.5.0 +- OpenHands 1.16.0 Every source can target every destination, including itself. A migration creates a new independent target session; it does not move/delete the source, @@ -40,7 +43,7 @@ bodies or titles. `convert` accepts one file-based source and produces: -1. one complete target artifact or official OpenCode import bundle; and +1. one complete target artifact or official OpenCode/Kilo import bundle; and 2. one adjacent schema-v2 content-free manifest. It never installs or invokes a target CLI. @@ -49,10 +52,10 @@ It never installs or invokes a target CLI. `import` converts and installs into the target's native store. It validates the artifact before publication, refuses every collision, and writes a private -manifest. OpenCode uses only its official pinned importer. Antigravity and -Cursor use clean-room, exact-version database installers. Vibe and Kimi publish -their native multi-file session directories; Muse and Qwen publish one native -JSONL plus a manifest. +manifest. OpenCode and Kilo use only their official pinned importers. +Antigravity and Cursor use clean-room, exact-version database installers. Vibe, +Kimi, Grok, and OpenHands publish their native multi-file session directories; +Muse and Qwen publish one native JSONL plus a manifest. ### Transfer @@ -66,7 +69,7 @@ The catalog exhaustively enumerates all recognized sessions within configured, auto-detected, or explicitly bounded-discovered roots. It indexes native names/titles and IDs, including archives, parents/subagents, duplicates, unsupported/corrupt entries, missing Copilot/Cursor stores, and -OMP/Vibe/Muse/Qwen/Kimi sessions. It does not + OMP/Vibe/Muse/Qwen/Kimi/Grok/Kilo/OpenHands sessions. It does not promise whole-disk discovery or content search. ## Portable event model @@ -110,13 +113,14 @@ counted as unsupported for that experimental target. Each writer has a pinned schema/build. `--target-cli-version` changes only a metadata label and emits a warning; it never selects a different architecture. -Automatic OpenCode, Antigravity, and Cursor install requires the exact pinned -runtime. Cursor additionally requires exact digests/sizes for four shipped -artifacts. +Automatic OpenCode, Kilo, Antigravity, and Cursor install requires the exact +pinned runtime. Cursor additionally requires exact digests/sizes for four +shipped artifacts. OMP's current fixed-title-slot v3 writer is pinned to 18.0.5. Vibe's writer and append-boundary fingerprint are pinned to 2.24.3. Muse, -Qwen, and Kimi writers are pinned to 0.2.1, 0.22.1, and 0.38.0. Installation +Qwen, and Kimi writers are pinned to 0.2.1, 0.22.1, and 0.38.0. Grok, Kilo, +and OpenHands are pinned to 1.0.5, 7.5.0, and 1.16.0. Installation does not invoke Vibe; the exact CLI is exercised by the credential-free native resume gate. @@ -131,7 +135,7 @@ fail closed. - New application/session directories are private (`0700`). - Existing directory permissions are preserved. - Publication is no-clobber and rollback-aware. -- OpenCode SQLite is never directly written. +- OpenCode and Kilo SQLite are never directly written. - Antigravity's summary database is updated transactionally only as part of its verified native install. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4c0724a..edff0fd 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -29,6 +29,9 @@ smigrate inspect SOURCE --format vibe smigrate inspect SOURCE --format muse smigrate inspect SOURCE --format qwen smigrate inspect SOURCE --format kimi +smigrate inspect SOURCE --format grok +smigrate inspect SOURCE --format kilo +smigrate inspect SOURCE --format openhands ``` Forcing a format bypasses only detection. The adapter still rejects malformed, @@ -74,7 +77,7 @@ Claude's encoded project directory can collide. Supply the exact project CWD: smigrate transfer UUID --from claude --source-cwd /absolute/project --to codex ``` -Pi, OMP, Cursor, Vibe, Qwen, and Kimi also accept `--source-cwd` to choose a +Pi, OMP, Cursor, Vibe, Qwen, Kimi, and Grok also accept `--source-cwd` to choose a workspace-specific native store. ## Oh My Pi session is missing or detected as Pi @@ -201,6 +204,24 @@ OpenRouter test procedure is documented in [the format note](muse-qwen-kimi-formats.md); it is a release oracle, not a credential-migration feature. +## Grok, Kilo Code, or OpenHands session is missing or rejected + +These adapters are pinned to Grok `1.0.5`, Kilo Code `7.5.0`, and OpenHands +`1.16.0`. Grok and OpenHands use filesystem homes: + +```text +$GROK_HOME/sessions/// +$OPENHANDS_CONVERSATIONS_DIR//events/ +``` + +Use `--source-cwd` when a Grok UUID is ambiguous. Kilo is a virtual source and +target backed by its normal XDG SQLite inventory; it deliberately rejects +`--home` and uses the exact official binary selected by `--source-cli` or +`--target-cli`. Kilo 7.5.0's JSON session-list command crashes on some valid +imports, so session-migrate uses an official per-ID export probe and discards +its body during collision checks. See +[the pinned format contracts](grok-kilo-openhands-formats.md). + ## Codex paginated or history-base source These lineage modes are recognized but unsupported. `--format codex` cannot @@ -229,7 +250,9 @@ smigrate catalog roots list smigrate catalog refresh --discover-under /bounded/workspace smigrate catalog refresh --cursor-root /custom/cursor \ --antigravity-root /custom/agy --vibe-root /custom/vibe \ - --muse-root /custom/muse --qwen-root /custom/qwen --kimi-root /custom/kimi + --muse-root /custom/muse --qwen-root /custom/qwen --kimi-root /custom/kimi \ + --grok-root /custom/grok --kilo-root /custom/kilo \ + --openhands-root /custom/openhands/conversations ``` Arbitrary custom directory names require explicit registration. Search defaults @@ -238,7 +261,7 @@ Use `--include-paths` only when path/CWD exposure is acceptable. Catalog statuses are structural by default. `candidate` is not a conversion guarantee; use `refresh --validate` or rely on authoritative validation during -transfer. OpenCode candidates are validated only when the selected ID is +transfer. OpenCode and Kilo candidates are validated only when the selected ID is officially exported. ## Authentication or model failure after successful resume diff --git a/docs/validation-report.md b/docs/validation-report.md index 3cda2c3..0d7f2db 100644 --- a/docs/validation-report.md +++ b/docs/validation-report.md @@ -1,6 +1,6 @@ # Thorough validation report -Date: 2026-08-18; updated 2026-08-25 for Muse, Qwen Code, Kimi Code, and OMP support +Date: 2026-08-18; updated 2026-08-26 for Grok, Kilo Code, and OpenHands support This report records the validation campaign requested after the v0.1 baseline. It deliberately separates native acceptance, portable semantic equivalence, @@ -1119,6 +1119,50 @@ Ruff lint and format checks, diff checks, sdist/wheel build, both isolated wheel entry points, and a packaged Claude→OMP→inspect smoke test passed. The wheel reported `omp` in source/target choices and exposed `--omp-root`. +### v0.9.0 Grok, Kilo Code, and OpenHands gate + +Three first-class readable/writable adapters were added for exact Grok 1.0.5, +Kilo Code 7.5.0, and OpenHands CLI 1.16.0 / SDK 1.21.0 builds. Sanitized +fixtures cover ordered text, images, linked tools/results, compaction or +condensation, native titles, version metadata, and reason-specific loss +counters. Malformed-input tests cover identity/linkage mismatches, unsafe +paths, duplicate records, bounded JSON depth/counts, and live multi-file +replacement/append races. + +The route oracle exercised all **225 ordered source/target pairs** and reparsed +every generated target before comparing the target-specific portable timeline. +Discovery, content-free inspection, catalog refresh/title search, direct and +catalog-ID transfer, collisions, same-format rewrites, and packaged CLI choices +are covered for all three new formats. OpenCode-lineage bundles fail closed +during autodetection because Kilo and OpenCode share a schema with no reliable +producer marker; explicit `--format` selection is tested for both. + +Credential-free native gates used a loopback OpenAI-compatible server and the +exact pinned Linux x64 artifacts. Grok resumed and appended to its paired ACP +update stream; Kilo imported, continued, and re-exported through only its +official commands; OpenHands loaded SDK event files and appended native user +and assistant events without modifying the imported event prefix. Model +requests independently contained markers available only in imported history. +No provider key, account state, or network model was used. + +The same exact-binary tests then opened each installed session through a real +interactive terminal path in a bounded PTY. Grok's fullscreen TUI and Kilo's +mini TUI rendered the imported compaction, final history, and appended reply. +OpenHands' TUI opened the imported conversation ID; its native `view` command +rendered the imported user/tool history and appended reply. This separates +visual/native presentation from the model-context assertion instead of treating +a successful headless exit as sufficient evidence. + +The release gate also checks coherent paired/directory reads while a native CLI +may be writing. Grok validates `summary.num_messages`, file identities, finite +JSON, depth, and total nodes. OpenHands snapshots its bounded event inventory +and derived metadata before and after reading and validates event/action +linkage. It deliberately does not fabricate a partial `base_state.json`: the +pinned SDK rebuilds the complete runtime snapshot on first resume, and the +native gate verifies the resulting CWD/model state. Kilo installation runs the +official importer from the requested workspace because 7.5.0 otherwise +rewrites the imported CWD. + ## Known boundaries - Codex paginated history and `history_base` lineage remain fail-closed until diff --git a/llms.txt b/llms.txt index 6d21e0a..c1958dc 100644 --- a/llms.txt +++ b/llms.txt @@ -9,7 +9,7 @@ session. Read the session selector, source agent, and target agent from the user's message. If any value is missing, ask for it before changing state. Accepted format names are: claude, codex, pi, omp, opencode, copilot, antigravity, -vibe, muse, qwen, kimi, and cursor. Cursor is experimental, version-pinned, +vibe, muse, qwen, kimi, grok, kilo, openhands, and cursor. Cursor is experimental, version-pinned, and text-only. ## Procedure diff --git a/website/app/CopyPrompt.tsx b/website/app/CopyPrompt.tsx index 47182c2..4d434e8 100644 --- a/website/app/CopyPrompt.tsx +++ b/website/app/CopyPrompt.tsx @@ -14,6 +14,9 @@ const agents = [ ["muse", "Muse Code"], ["qwen", "Qwen Code"], ["kimi", "Kimi Code"], + ["grok", "Grok"], + ["kilo", "Kilo Code"], + ["openhands", "OpenHands"], ["cursor", "Cursor"], ] as const; diff --git a/website/app/globals.css b/website/app/globals.css index af24d6e..f1c3d72 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -220,15 +220,15 @@ h1 { margin: 28px auto 24px; max-width: 1100px; font-size: clamp(58px, 7.1vw, 96 .stream-status { margin-top: 18px; padding: 14px; color: #71747c; background: #0b0d11; border-radius: 7px; font-size: 9px; text-align: center; } .stream-status span { margin-right: 6px; color: var(--lime); } -.capability-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: 14px; } -.capability-list article { display: flex; gap: 9px; align-items: center; min-height: 104px; padding: 18px 12px; background: #0d0e12; } -.capability-list article > i { width: 8px; height: 8px; flex: 0 0 auto; background: var(--lime); border-radius: 50%; box-shadow: 0 0 13px rgba(184,249,74,.42); } -.capability-list article.experimental > i { background: var(--blue); box-shadow: 0 0 13px rgba(138,193,255,.42); } -.capability-list article.vibe > i { background: #ff9c52; box-shadow: 0 0 13px rgba(255,156,82,.45); } +.capability-list { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: 14px; } +.capability-list > a { display: flex; gap: 12px; align-items: center; min-height: 104px; padding: 18px 14px; background: #0d0e12; } +.capability-list > a:hover { background: #12141a; } +.capability-list > a:focus-visible { position: relative; z-index: 1; outline: 2px solid var(--blue); outline-offset: -3px; } +.capability-list img { width: 42px; height: 42px; flex: 0 0 auto; object-fit: contain; border-radius: 9px; box-shadow: 0 8px 24px rgba(0,0,0,.24); } .capability-list h3 { margin-bottom: 4px; color: #dedfd9; font-size: 14px; } .capability-list p { color: #6f727b; font: 9px/1.5 var(--font-geist-mono), monospace; text-transform: uppercase; letter-spacing: .05em; } -.capability-list article.experimental p { color: #88a9d0; } -.capability-list article.vibe p { color: #dca67d; } +.capability-list a.experimental p { color: #88a9d0; } +.capability-list a.vibe p { color: #dca67d; } .capability-note { margin: 15px 2px 0; color: #686b74; font: 10px/1.6 var(--font-geist-mono), monospace; text-transform: uppercase; letter-spacing: .06em; } .feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin-bottom: 155px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: 14px; } @@ -309,7 +309,7 @@ h1 { margin: 28px auto 24px; max-width: 1100px; font-size: clamp(58px, 7.1vw, 96 .stream-card { padding: 18px 14px; } .pipeline-step { grid-template-columns: 38px 1fr; gap: 11px; padding: 15px 13px; } .pipeline-arrow { margin-left: 30px; } - .capability-list article { min-height: 92px; padding: 16px 13px; } + .capability-list > a { min-height: 92px; padding: 16px 13px; } .capability-list h3 { font-size: 12px; } .feature-grid { margin-bottom: 100px; } .install { gap: 45px; padding-block: 100px; } @@ -318,3 +318,6 @@ h1 { margin: 28px auto 24px; max-width: 1100px; font-size: clamp(58px, 7.1vw, 96 .footer > div { justify-content: center; } .footer .brand { justify-self: center; } } +@media (max-width: 480px) { + .capability-list { grid-template-columns: 1fr; } +} diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 2aaf78d..c124052 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -9,8 +9,8 @@ const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] const origin = "https://session-migrate.github.io"; const title = "session-migrate — Migrate your sessions to any harness"; -const description = "Move coding agent sessions among Claude Code, Codex, Pi, Oh My Pi, OpenCode, Copilot, Antigravity, Mistral Vibe, Muse, Qwen, Kimi, and Cursor."; -const image = `${origin}/og-twelve-harnesses.png`; +const description = "Move coding agent sessions among Claude Code, Codex, Pi, Oh My Pi, OpenCode, Copilot, Antigravity, Mistral Vibe, Muse, Qwen, Kimi, Grok, Kilo Code, OpenHands, and Cursor."; +const image = `${origin}/og-fifteen-harnesses.png`; export const metadata: Metadata = { metadataBase: new URL(`${origin}/`), @@ -27,7 +27,7 @@ export const metadata: Metadata = { url: image, width: 1731, height: 909, - alt: "Migrate sessions among Claude Code, Codex, Pi, Oh My Pi, OpenCode, Copilot, Antigravity, Mistral Vibe, Muse, Qwen, Kimi, and Cursor", + alt: "Migrate sessions among fifteen coding agents, from Claude Code and Codex to Grok, Kilo Code, OpenHands, and Cursor", }], }, twitter: { card: "summary_large_image", title, description, images: [image] }, diff --git a/website/app/page.tsx b/website/app/page.tsx index 2e13ba7..6c13958 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -4,20 +4,23 @@ import { CopyPrompt } from "./CopyPrompt"; import { HeroTerminal } from "./HeroTerminal"; import { LiveTrajectory } from "./LiveTrajectory"; -const agents = ["Claude", "Codex", "Pi", "Oh My Pi", "OpenCode", "Copilot", "Antigravity", "Vibe", "Muse", "Qwen", "Kimi", "Cursor*"]; +const agents = ["Claude", "Codex", "Pi", "Oh My Pi", "OpenCode", "Copilot", "Antigravity", "Vibe", "Muse", "Qwen", "Kimi", "Grok", "Kilo", "OpenHands", "Cursor*"]; const capabilities = [ - ["Claude Code", "Full adapter"], - ["Codex", "Full adapter"], - ["Pi", "Full adapter"], - ["Oh My Pi", "Full adapter · 18.0.5"], - ["OpenCode", "Full adapter"], - ["Copilot", "Full adapter"], - ["Antigravity", "Full adapter"], - ["Mistral Vibe", "Full adapter · 2.24.3"], - ["Muse Code", "Full adapter · 0.2.1"], - ["Qwen Code", "Full adapter · 0.22.1"], - ["Kimi Code", "Full adapter · 0.38.0"], - ["Cursor", "Text only · experimental"], + ["Claude Code", "Full adapter", "claude-code", "https://github.com/anthropics/claude-code"], + ["Codex", "Full adapter", "codex", "https://github.com/openai/codex"], + ["Pi", "Full adapter", "pi", "https://pi.dev"], + ["Oh My Pi", "Full adapter · 18.0.5", "oh-my-pi", "https://github.com/can1357/oh-my-pi"], + ["OpenCode", "Full adapter", "opencode", "https://github.com/anomalyco/opencode"], + ["Copilot", "Full adapter", "copilot", "https://github.com/github/copilot-cli"], + ["Antigravity", "Full adapter", "antigravity", "https://developers.google.com/antigravity"], + ["Mistral Vibe", "Full adapter · 2.24.3", "mistral-vibe", "https://github.com/mistralai/mistral-vibe"], + ["Muse Code", "Full adapter · 0.2.1", "muse", "https://dev.meta.ai/"], + ["Qwen Code", "Full adapter · 0.22.1", "qwen-code", "https://github.com/QwenLM/qwen-code"], + ["Kimi Code", "Full adapter · 0.38.0", "kimi-code", "https://github.com/MoonshotAI/kimi-cli"], + ["Grok", "Full adapter · 1.0.5", "grok", "https://github.com/xai-org/grok-build"], + ["Kilo Code", "Full adapter · 7.5.0", "kilo-code", "https://github.com/Kilo-Org/kilocode"], + ["OpenHands", "Full adapter · 1.16.0", "openhands", "https://github.com/All-Hands-AI/OpenHands"], + ["Cursor", "Text only · experimental", "cursor", "https://cursor.com/cli"], ] as const; function BrandMark({ hero = false }: { hero?: boolean }) { @@ -55,8 +58,8 @@ export default function Home() {

Switch agents.
Keep your context.

Move coding agent sessions among Claude Code, Codex, Pi, Oh My Pi, OpenCode, - Copilot, Antigravity, Mistral Vibe, Muse, Qwen, Kimi, and - Cursor—then resume where you left off. + Copilot, Antigravity, Mistral Vibe, Muse, Qwen, Kimi, Grok, + Kilo Code, OpenHands, and Cursor—then resume where you left off.

@@ -111,13 +114,14 @@ export default function Home() { Move between any two listed agents. A same-agent move creates a fresh native session instead of copying bytes.
- {capabilities.map(([name, detail]) => ( -
-