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
+
+
+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]) => (
-
-
-
+ {capabilities.map(([name, detail, icon, href]) => (
+
+
+
+
))}
- All 144 source → target routes are available. Cursor is version-pinned and experimental.
+ All 225 source → target routes are available. Cursor is version-pinned and experimental.
diff --git a/website/public/llms.txt b/website/public/llms.txt
index 6d21e0a..c1958dc 100644
--- a/website/public/llms.txt
+++ b/website/public/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/scripts/render-og.mjs b/website/scripts/render-og.mjs
deleted file mode 100644
index 68d357c..0000000
--- a/website/scripts/render-og.mjs
+++ /dev/null
@@ -1,18 +0,0 @@
-import { copyFile, readFile } from "node:fs/promises";
-import { fileURLToPath } from "node:url";
-import path from "node:path";
-import sharp from "sharp";
-
-const directory = path.dirname(fileURLToPath(import.meta.url));
-const source = path.resolve(directory, "../assets/og.svg");
-const output = path.resolve(directory, "../public/og-twelve-harnesses.png");
-const legacyOutput = path.resolve(directory, "../public/og.png");
-const svg = await readFile(source);
-
-await sharp(svg, { density: 144 })
- .resize(1731, 909, { fit: "fill" })
- .png({ compressionLevel: 9, palette: false })
- .toFile(output);
-await copyFile(output, legacyOutput);
-
-console.log(`Rendered ${path.relative(process.cwd(), output)}`);
diff --git a/website/tests/rendered-html.test.mjs b/website/tests/rendered-html.test.mjs
index a789fcd..17edced 100644
--- a/website/tests/rendered-html.test.mjs
+++ b/website/tests/rendered-html.test.mjs
@@ -55,7 +55,7 @@ test("ships local native casts and the vendored player", async () => {
assert.match(logo, / {
+test("preserves the previous twelve-harness social preview artifact", async () => {
const [source, preview] = await Promise.all([
readFile(new URL("../assets/og.svg", import.meta.url), "utf8"),
readFile(new URL("../public/og-twelve-harnesses.png", import.meta.url)),
@@ -144,12 +144,15 @@ test("server-renders the complete project landing page", async () => {
assert.doesNotMatch(html, /c3f7…|1000…|2000…|3000…/);
assert.match(html, /history continued · ready to resume/);
assert.doesNotMatch(html, /