diff --git a/CHANGELOG.md b/CHANGELOG.md index 75cf1ff..f9bd94e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1. ## [Unreleased] +### Added + +- Register a fullscreen viewer in the app list under **…** in Codex's left toolbar, where users can pin it, alongside the existing task right-panel entry. Both use the same app-only tool, open the exact task when valid context is available, and show a local task selector when it is unavailable. + ## [1.0.0] - 2026-10-02 ### Added diff --git a/README.md b/README.md index 4c51f7f..fcfd3ab 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ See what happened in a Codex task, where time went, and how many tokens it used. -Codex Trajectory is a plugin that turns local Codex logs into a searchable event ledger and interactive timeline. Open it directly in the Codex desktop side panel. +Codex Trajectory is a plugin that turns local Codex logs into a searchable event ledger and interactive timeline. Open it from the app list under **…** in Codex's left toolbar for a fullscreen viewer, or from a task's right panel. ![Codex Trajectory: English viewer](plugins/codex-trajectory/assets/screenshots/overview-en.png) @@ -39,11 +39,11 @@ Open a new Codex task to load the plugin. After updating an existing installatio ## Use -1. In the task's right panel, select **+ → More tools… → Plugins and MCPs → Codex Trajectory**. +1. Open **…** in Codex's left toolbar and choose **Codex Trajectory** for a fullscreen viewer; you can pin it to the toolbar. You can also select **+ → More tools… → Plugins and MCPs → Codex Trajectory** in the task's right panel. 2. Browse the timeline and event ledger. Use search and filters to find a record; select it to inspect it. 3. Click **Live window** to follow activity, or **Load earlier records** to browse history. -The native entry opens the current task without a model turn. If the host cannot identify that task, it shows a local task selector. +Both native entries open without a model turn. With valid task context, the viewer opens that exact task; without available task context, it shows a local task selector. You can also ask Codex: diff --git a/README.zh-CN.md b/README.zh-CN.md index c358657..9d2fe99 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,7 +10,7 @@ 看清 Codex 任务做了什么、时间花在哪里、用了多少 Token。 -Codex Trajectory 将本地 Codex 日志整理成可搜索的事件账本和交互时间轴,直接在 Codex 桌面端侧栏中打开。 +Codex Trajectory 将本地 Codex 日志整理成可搜索的事件账本和交互时间轴,可从 Codex 左侧工具栏 **…** 中的应用列表全屏打开,也可在任务右侧面板中打开。 ![Codex Trajectory 中文查看器](plugins/codex-trajectory/assets/screenshots/desktop-zh-CN.png) @@ -39,11 +39,11 @@ codex plugin add codex-trajectory@icesixgod ## 使用 -1. 在任务右侧面板中选择 **+ → More tools… → Plugins and MCPs → Codex Trajectory**。 +1. 在 Codex 左侧工具栏的 **…** 应用列表中打开 **Codex Trajectory**,进入全屏查看器,并可将其固定到工具栏。也可在任务右侧面板中选择 **+ → More tools… → Plugins and MCPs → Codex Trajectory**。 2. 浏览时间轴和事件账本,用搜索与筛选定位记录,点击记录查看详情。 3. 点击 **实时小窗**跟随任务活动,或点击**加载更早记录**浏览历史。 -原生入口直接打开当前任务,不触发模型对话。宿主无法识别当前任务时,会显示本地任务选择器。 +两个原生入口都不触发模型对话。有有效任务上下文时,查看器精确打开该任务;任务上下文不可用时,显示本地任务选择器。 也可以直接对 Codex 说: diff --git a/docs/interface.md b/docs/interface.md index a2ebe07..6cc7f86 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -2,9 +2,9 @@ Codex Trajectory exposes three user-facing session MCP tools, four app-only helpers, and one `text/html;profile=mcp-app` resource at `ui://codex-trajectory/trajectory-v2.html`. The viewer uses the native MCP Apps host and exposes no task-control actions. `list_codex_sessions` updates a private derived index and advertises `readOnlyHint: false`; it never edits source task logs. -## Native conversation panel +## Native viewer entrypoints -`open_codex_trajectory` has an empty input schema and app-only visibility. Its descriptor declares `_meta.ui.resourceUri` and `_meta["openai/ui"].entrypoints: [{"type": "thread"}]`. The host can open it from the conversation's right-panel tools menu without a model turn. +`open_codex_trajectory` has an empty input schema and app-only visibility. Its descriptor declares `_meta.ui.resourceUri` and `_meta["openai/ui"].entrypoints: [{"type": "global"}, {"type": "thread"}]`. The global entry registers the viewer in the app list under **…** in Codex's left toolbar for fullscreen opening. Users can pin it to the toolbar; pinning is controlled by Codex rather than the plugin manifest. The thread entry retains the conversation's right-panel tools menu. Both use the same tool and open without a model turn. Valid task context opens that exact task; unavailable context shows a local task selector. After updating an existing installation, fully quit and reopen Codex to load the new registrations. The dispatcher passes request `_meta` to this tool without storing task context in global state. On the tested Codex desktop client, `thread_id` and `threadId` contain the calling task ID. Either alias is accepted; both must match when present. Values must be bounded identifiers and resolve to the exact local task, including an archived task. Both successful reads and read-limit errors require an actual matching `session_meta.id` or legacy `session_meta.session_id`; missing or invalid metadata identities return task selection, even when the filename matches. Prefixes, file aliases, process `CODEX_THREAD_ID`, and anonymized `openai/session` metadata never substitute for native caller identity. The same exact-ID check applies to read-limit errors before offering an increased-budget retry; a mismatched or unavailable error identity returns task selection. When compressed metadata cannot be decoded within the current budget, filename-derived identities remain unverified and the native entry returns task selection. Increased-budget retries verify the returned session ID before displaying any trajectory. Missing, invalid, conflicting, or unavailable context returns `{viewerState: "select-session", reason}` with the same UI resource; the user explicitly chooses a task before any trajectory is displayed. This initialization envelope is separate from the frozen trajectory schemas. diff --git a/docs/native-viewer.md b/docs/native-viewer.md index addf009..22af429 100644 --- a/docs/native-viewer.md +++ b/docs/native-viewer.md @@ -1,6 +1,6 @@ # Native viewer migration -The viewer now uses Codex's native conversation-panel extension. Open the right panel's **+ → More tools… → Plugins and MCPs → Codex Trajectory** entry. It opens the current task's safe summary without a model turn. A missing or unavailable task context shows a selector, so the viewer never guesses another task from recent activity. +The viewer uses Codex's native global and conversation-panel extensions. Open **…** in Codex's left toolbar and choose **Codex Trajectory** for a fullscreen viewer; you can pin it to the toolbar. The existing right-panel **+ → More tools… → Plugins and MCPs → Codex Trajectory** entry remains available. Both entries use the same app-only tool and open without a model turn. Valid task context opens that exact task's safe summary. A missing or unavailable task context shows a local task selector, so the viewer never guesses another task from recent activity. After updating an existing installation, fully quit and reopen Codex to load the new entries. Toolbar pinning is controlled by Codex, and the plugin manifest cannot request automatic pinning. The public session-list, trajectory-read, and interactive-show tools retain their defaults. Live Token/cost/quota views, compressed and paginated history, bounded full-detail confirmation, and the frozen v1/v2 schemas are preserved. Task status reflects the selected task; live account quota uses the newest valid sample found in recent local active-task logs, updates independently of task revisions, and shows its sample time in the tooltip. Both can lag the service. Manual stopping and quota-triggered automatic stopping have been removed. @@ -8,7 +8,7 @@ After **Load full details**, leave **Default to full details for all tasks on th ## Structure -- `tools.py` owns MCP descriptors, argument dispatch, and native opening. +- `tools.py` owns MCP descriptors, argument dispatch, and native opening. The native tool embeds the packaged plugin icon as a PNG data URI for the toolbar and panel entries. - `native_view.py` validates per-call context and defines selection responses. - `protocol.py` transports request metadata without shared task identity. - `projection.py` projects bounded local rollouts and loads the self-contained UI resource. diff --git a/plugins/codex-trajectory/.codex-plugin/plugin.json b/plugins/codex-trajectory/.codex-plugin/plugin.json index b2c0430..eea838d 100644 --- a/plugins/codex-trajectory/.codex-plugin/plugin.json +++ b/plugins/codex-trajectory/.codex-plugin/plugin.json @@ -21,7 +21,7 @@ "interface": { "displayName": "Codex Trajectory", "shortDescription": "Inspect turns, tools, tokens, and timing in local Codex tasks.", - "longDescription": "A privacy-aware trajectory viewer for local Codex tasks. Open the calling task from the native conversation side panel, inspect safe summaries or opt-in bounded details, and follow live events, tokens, local cost estimates, and quota windows without a debugging port.", + "longDescription": "A privacy-aware trajectory viewer for local Codex tasks. Open it in fullscreen from the app list under … in Codex's left toolbar, pin it there, or open it from a task's native right panel. Valid task context opens that exact task; otherwise, choose a local task. Inspect safe summaries or opt-in bounded details, and follow live events, tokens, local cost estimates, and quota windows without a debugging port.", "developerName": "icesixgod", "category": "Developer Tools", "capabilities": [ diff --git a/plugins/codex-trajectory/README.md b/plugins/codex-trajectory/README.md index 94f95ba..8486e46 100644 --- a/plugins/codex-trajectory/README.md +++ b/plugins/codex-trajectory/README.md @@ -6,7 +6,9 @@ View local Codex task history as an interactive timeline and event ledger, with timing, tokens, estimated costs, and live activity. -Open **+ → More tools… → Plugins and MCPs → Codex Trajectory** in the task's right panel. The native entry opens the current task's safe summary without a model turn; unavailable task context shows a selector. Full details require confirmation, and live views always use safe summaries. Source logs remain unchanged. +Open **…** in Codex's left toolbar and choose **Codex Trajectory** for a fullscreen viewer; you can pin it to the toolbar. You can also choose **+ → More tools… → Plugins and MCPs → Codex Trajectory** in the task's right panel. Both native entries open without a model turn. Valid task context opens that exact task's safe summary; unavailable context shows a local task selector. Full details require confirmation, and live views always use safe summaries. Source logs remain unchanged. + +After updating an existing installation, fully quit and reopen Codex to load the new entries. See the [English README](../../README.md) for installation, screenshots, usage, and privacy. Technical details are in the [interface reference](../../docs/interface.md) and [native viewer guide](../../docs/native-viewer.md). @@ -16,6 +18,8 @@ See the [English README](../../README.md) for installation, screenshots, usage, 将本地 Codex 任务历史展示为交互时间轴和事件账本,查看耗时、Token、估算费用和实时活动。 -在任务右侧面板中打开 **+ → More tools… → Plugins and MCPs → Codex Trajectory**。原生入口直接展示当前任务的安全摘要,不触发模型对话;无法识别任务时显示选择器。完整详情需要确认,实时视图始终使用安全摘要,源日志保持不变。 +在 Codex 左侧工具栏的 **…** 应用列表中打开 **Codex Trajectory**,进入全屏查看器,并可将其固定到工具栏。也可在任务右侧面板中选择 **+ → More tools… → Plugins and MCPs → Codex Trajectory**。两个原生入口都不触发模型对话。有有效任务上下文时,精确展示该任务的安全摘要;上下文不可用时,显示本地任务选择器。完整详情需要确认,实时视图始终使用安全摘要,源日志保持不变。 + +更新已有安装后,请完整退出并重新打开 Codex,以加载新入口。 安装、截图、使用和隐私说明见[中文 README](../../README.zh-CN.md)。技术细节见[接口文档](../../docs/interface.md)和[原生查看器指南](../../docs/native-viewer.md)。 diff --git a/plugins/codex-trajectory/scripts/codex_trajectory/tools.py b/plugins/codex-trajectory/scripts/codex_trajectory/tools.py index fca05c5..d45730c 100644 --- a/plugins/codex-trajectory/scripts/codex_trajectory/tools.py +++ b/plugins/codex-trajectory/scripts/codex_trajectory/tools.py @@ -2,6 +2,9 @@ from __future__ import annotations +from base64 import b64encode +from functools import cache +from pathlib import Path from typing import Any from . import projection @@ -17,6 +20,13 @@ from .viewer_preferences import default_full_details, set_default_full_details +@cache +def _viewer_icon_uri() -> str: + """Embed the packaged icon for native hosts that cannot load local paths.""" + icon = Path(__file__).parents[2] / "assets" / "icon.png" + return "data:image/png;base64," + b64encode(icon.read_bytes()).decode("ascii") + + def current_trajectory_result(metadata: dict[str, Any] | None) -> dict[str, Any]: """Open the exact calling task, or let the user choose a local task.""" try: @@ -162,14 +172,16 @@ def tool_definitions() -> list[dict[str, Any]]: { "name": "open_codex_trajectory", "title": "Codex Trajectory", + "icons": [{"src": _viewer_icon_uri(), "mimeType": "image/png"}], "description": ( - "Open the calling Codex task's safe trajectory in the native side panel." + "Open the native trajectory viewer from the sidebar or task side panel. " + "Show the calling task's safe summary, or a task selector without task context." ), "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, "annotations": read_only, "_meta": { "ui": {"resourceUri": UI_URI, "visibility": ["app"]}, - "openai/ui": {"entrypoints": [{"type": "thread"}]}, + "openai/ui": {"entrypoints": [{"type": "global"}, {"type": "thread"}]}, "openai/visibility": "private", "openai/outputTemplate": UI_URI, }, diff --git a/plugins/codex-trajectory/tests/test_native_view.py b/plugins/codex-trajectory/tests/test_native_view.py index aa8aba4..94c6d46 100644 --- a/plugins/codex-trajectory/tests/test_native_view.py +++ b/plugins/codex-trajectory/tests/test_native_view.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from base64 import b64decode from pathlib import Path from typing import Any @@ -24,13 +25,42 @@ def test_native_entrypoint_is_app_only_and_read_only() -> None: tool = next( item for item in projection.tool_definitions() if item["name"] == "open_codex_trajectory" ) - assert tool["_meta"]["openai/ui"]["entrypoints"] == [{"type": "thread"}] + assert tool["_meta"]["openai/ui"]["entrypoints"] == [{"type": "global"}, {"type": "thread"}] assert tool["_meta"]["ui"] == {"resourceUri": projection.UI_URI, "visibility": ["app"]} assert tool["_meta"]["openai/visibility"] == "private" assert tool["annotations"]["readOnlyHint"] is True assert tool["inputSchema"]["additionalProperties"] is False +def test_native_entrypoint_embeds_the_packaged_icon() -> None: + tool = next( + item for item in projection.tool_definitions() if item["name"] == "open_codex_trajectory" + ) + icon = tool["icons"][0] + prefix, data = icon["src"].split(",", 1) + assert prefix == "data:image/png;base64" + assert icon["mimeType"] == "image/png" + assert ( + b64decode(data, validate=True) + == (Path(__file__).parents[1] / "assets" / "icon.png").read_bytes() + ) + + +def test_sidebar_without_context_selects_before_reading_a_trajectory( + codex_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def unexpected_read(*args: Any, **kwargs: Any) -> dict[str, Any]: + pytest.fail("A sidebar entry without task context must wait for explicit selection.") + + monkeypatch.setattr(projection, "trajectory_result", unexpected_read) + result = open_panel() + assert result["structuredContent"] == { + "viewerState": "select-session", + "reason": "missing-context", + } + assert result["_meta"]["ui"]["resourceUri"] == projection.UI_URI + + def test_native_panel_binds_exact_caller_instead_of_latest(codex_home: Path) -> None: write_rollout(codex_home / "sessions" / "rollout-newer.jsonl", rollout_events("session-newer")) result = open_panel({"thread_id": "session-alpha", "threadId": "session-alpha"}) diff --git a/plugins/codex-trajectory/tests/test_protocol.py b/plugins/codex-trajectory/tests/test_protocol.py index 98f7347..4fd09db 100644 --- a/plugins/codex-trajectory/tests/test_protocol.py +++ b/plugins/codex-trajectory/tests/test_protocol.py @@ -63,7 +63,8 @@ def test_tool_definitions_scope_reads_and_native_panel() -> None: ) assert tools["show_codex_trajectory"]["_meta"]["ui"]["resourceUri"] == UI_URI assert tools["open_codex_trajectory"]["_meta"]["openai/ui"]["entrypoints"] == [ - {"type": "thread"} + {"type": "global"}, + {"type": "thread"}, ] update = tools["get_codex_trajectory_update"] assert update["_meta"]["ui"]["visibility"] == ["app"] diff --git a/plugins/codex-trajectory/tests/test_ui.py b/plugins/codex-trajectory/tests/test_ui.py index 3e56c91..540118d 100644 --- a/plugins/codex-trajectory/tests/test_ui.py +++ b/plugins/codex-trajectory/tests/test_ui.py @@ -2083,6 +2083,39 @@ def test_native_unavailable_task_can_retry_and_select(page: Page, harness_url: s expect(frame.locator("#refresh")).to_be_visible() +@pytest.mark.parametrize("language", ["en", "zh"]) +def test_sidebar_fullscreen_selects_a_task_and_returns_from_live_view( + page: Page, harness_url: str, language: str +) -> None: + page.goto(f"{harness_url}/{language}-native-sidebar") + frame = viewer(page) + select = frame.locator("#sessionSelect") + expect(select.locator('option[value="session-beta"]')).to_have_count(1) + expect(select).to_have_value("") + assert frame.locator("#ledger").count() == 0 + assert set(page.evaluate("window.__trajectoryToolNames")) == { + "list_codex_sessions", + "get_codex_trajectory_preferences", + } + + select.select_option("session-beta") + expect(frame.locator("#refresh")).to_be_visible() + expect(select).to_have_value("session-beta") + calls = page.evaluate("window.__trajectoryCalls") + assert any( + call.get("sessionId") == "session-beta" and call.get("detailLevel") == "summary" + for call in calls + ) + frame.locator("#openPip").click() + expect(frame.locator("#liveDock")).to_have_attribute("data-session-id", "session-beta") + frame.locator("#closeDock").click() + expect(frame.locator("#refresh")).to_be_visible() + expect(select).to_have_value("session-beta") + assert page.evaluate("window.__trajectoryDisplayModes") == [] + assert "ui/message" not in page.evaluate("window.__trajectoryProtocol") + assert frame.get_by_role("alert").count() == 0 + + def test_native_host_context_controls_theme_locale_and_colors(page: Page, harness_url: str) -> None: page.goto(f"{harness_url}/zh-native") frame = viewer(page) diff --git a/plugins/codex-trajectory/tests/ui_harness.py b/plugins/codex-trajectory/tests/ui_harness.py index de6ec25..9d10b05 100644 --- a/plugins/codex-trajectory/tests/ui_harness.py +++ b/plugins/codex-trajectory/tests/ui_harness.py @@ -844,6 +844,8 @@ def do_GET(self) -> None: "/en-native-selector", "/en-native-missing", "/en-native-fullscreen", + "/en-native-sidebar", + "/zh-native-sidebar", "/en-native-read-limit", "/zh-native-read-limit", }: @@ -854,11 +856,13 @@ def do_GET(self) -> None: native_host=True, native_pip_unavailable=True, initial_display_mode=( - "fullscreen" if route == "/en-native-fullscreen" else "inline" + "fullscreen" + if route == "/en-native-fullscreen" or route.endswith("-sidebar") + else "inline" ), selector_reason=( "missing-context" - if route == "/en-native-selector" + if route == "/en-native-selector" or route.endswith("-sidebar") else "task-unavailable" if route == "/en-native-missing" else None diff --git a/scripts/smoke_mcp.py b/scripts/smoke_mcp.py index 30b6fcf..85b55ef 100644 --- a/scripts/smoke_mcp.py +++ b/scripts/smoke_mcp.py @@ -302,12 +302,19 @@ def main() -> None: "Live update tool is not app-only.", ) native_tool = tools["open_codex_trajectory"] + native_icons = native_tool.get("icons", []) + require( + len(native_icons) == 1 + and native_icons[0].get("mimeType") == "image/png" + and native_icons[0].get("src", "").startswith("data:image/png;base64,"), + "Native entrypoint is missing its embedded plugin icon.", + ) require( native_tool.get("_meta", {}).get("ui", {}).get("visibility") == ["app"] and native_tool.get("_meta", {}).get("openai/ui", {}).get("entrypoints") - == [{"type": "thread"}] + == [{"type": "global"}, {"type": "thread"}] and native_tool.get("annotations", {}).get("readOnlyHint") is True, - "Native panel entrypoint is not app-only and read-only.", + "Native sidebar and panel entrypoints are not app-only and read-only.", ) require( by_id[3]["result"]["resources"][0]["uri"].startswith("ui://"),