your-turn 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. your_turn-0.4.0.dist-info/METADATA +160 -0
  2. your_turn-0.4.0.dist-info/RECORD +47 -0
  3. your_turn-0.4.0.dist-info/WHEEL +4 -0
  4. your_turn-0.4.0.dist-info/entry_points.txt +3 -0
  5. your_turn-0.4.0.dist-info/licenses/LICENSE +21 -0
  6. yourturn/__init__.py +1 -0
  7. yourturn/app.py +158 -0
  8. yourturn/cli.py +122 -0
  9. yourturn/config.py +17 -0
  10. yourturn/engine.py +814 -0
  11. yourturn/harness/__init__.py +1 -0
  12. yourturn/harness/base.py +278 -0
  13. yourturn/harness/claude.py +522 -0
  14. yourturn/harness/codex.py +476 -0
  15. yourturn/harness/cursor.py +460 -0
  16. yourturn/heuristics.py +327 -0
  17. yourturn/install.py +467 -0
  18. yourturn/model.py +130 -0
  19. yourturn/plugin/.codex-plugin/plugin.json +35 -0
  20. yourturn/plugin/assets/your-turn-dark.png +0 -0
  21. yourturn/plugin/assets/your-turn-light.png +0 -0
  22. yourturn/plugin/skills/your-turn-handoff/SKILL.md +87 -0
  23. yourturn/state.py +65 -0
  24. yourturn/static/app-icon-dark-180.png +0 -0
  25. yourturn/static/app-icon-dark-192.png +0 -0
  26. yourturn/static/app-icon-dark-512.png +0 -0
  27. yourturn/static/app-icon-light-180.png +0 -0
  28. yourturn/static/app-icon-light-192.png +0 -0
  29. yourturn/static/app-icon-light-512.png +0 -0
  30. yourturn/static/app.css +809 -0
  31. yourturn/static/app.js +1703 -0
  32. yourturn/static/brand-leaf.webp +0 -0
  33. yourturn/static/favicon-alert.ico +0 -0
  34. yourturn/static/favicon-dark-alert.ico +0 -0
  35. yourturn/static/favicon-dark.ico +0 -0
  36. yourturn/static/favicon.ico +0 -0
  37. yourturn/static/header-dark.webp +0 -0
  38. yourturn/static/header-light.webp +0 -0
  39. yourturn/static/icon-alert.svg +9 -0
  40. yourturn/static/icon-dark-alert.svg +10 -0
  41. yourturn/static/icon-dark.svg +10 -0
  42. yourturn/static/icon.svg +9 -0
  43. yourturn/static/index.html +342 -0
  44. yourturn/static/navigation.js +21 -0
  45. yourturn/static/state-sync.js +114 -0
  46. yourturn/static/theme.js +46 -0
  47. yourturn/static/tokens.css +147 -0
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.5
2
+ Name: your-turn
3
+ Version: 0.4.0
4
+ Summary: An inbox for your coding agents. See which chat is waiting on you, what each one produced, and what to do next.
5
+ Project-URL: Homepage, https://github.com/martin-gomola/your-turn
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: fastapi>=0.110
10
+ Requires-Dist: uvicorn>=0.29
11
+ Description-Content-Type: text/markdown
12
+
13
+ <div align="center">
14
+
15
+ # Your Turn
16
+
17
+ <p align="center"><strong>Your agents are fast. You're the queue.</strong></p>
18
+
19
+ ![Works with Codex · Claude Code · Cursor](https://img.shields.io/badge/Works%20with-Codex%20%C2%B7%20Claude%20Code%20%C2%B7%20Cursor-56645f)
20
+ ![Local · Read-only](https://img.shields.io/badge/Local-Read--only-2f7a6b)
21
+
22
+ [Your inbox](#your-inbox) · [Quick start](#quick-start) · [Privacy](#privacy) ·
23
+ [Why install the handoff](#why-install-the-handoff) · [Docs](#docs)
24
+
25
+ </div>
26
+
27
+ When Codex, Claude Code, and Cursor run side by side, the slow part is your attention. The chat blocked on you sits in a window you are not looking at, and every answer is a long log.
28
+
29
+ ## Your inbox
30
+
31
+ <div align="center">
32
+
33
+ <img src="docs/images/desktop.png?v=1" width="800" alt="Your Turn dark inbox in Focus Mode, with Codex, Claude Code, and Cursor chats grouped by status">
34
+
35
+ <sub>Made-up demo chats from <code>make screenshots</code>, not real session data.</sub>
36
+
37
+ </div>
38
+
39
+ <details>
40
+ <summary>See the inbox beside a chat</summary>
41
+
42
+ <img src="docs/images/in-codex.png?v=1" width="800" alt="Codex chat beside the Your Turn dark inbox in Focus Mode">
43
+
44
+ </details>
45
+
46
+ <details>
47
+ <summary>Watch a chat move into Needs you</summary>
48
+
49
+ <img src="docs/images/demo.gif?v=1" width="800" alt="A chat moves into Needs you when its question arrives, then opens beside the inbox">
50
+
51
+ </details>
52
+ <br>
53
+
54
+ **Your Turn** is not another orchestrator, IDE wrapper, or cloud dashboard. It is a read-only, zero-config local inbox on `localhost:8787`. It reads the JSONL logs your agents already write and the local Cursor chat database. It answers three questions at a glance:
55
+
56
+ - ⏳ **Who is waiting on you?**<br>
57
+ Questions, pending approvals, and unreviewed results, ordered by how long they have waited.<br>
58
+ - ✅ **Can you trust the result without reading the log?**<br>
59
+ `Check failed` and `Edits unchecked` badges tied to the latest code edit.<br>
60
+ - ➡️ **What is the exact next step?**<br>
61
+ One line per chat, with one click to open or resume it in its app.<br>
62
+
63
+ Your Turn runs on your machine. It does not take over agent processes, run a background service, or send chat data to the cloud. It uses no cloud account or extra agent permissions.
64
+
65
+ ## Quick start
66
+
67
+ You need Python 3.11+, [`uv`](https://docs.astral.sh/uv/), and Codex, Claude Code, or Cursor. Without `uv`, install it first with `brew install uv` or [uv's installer](https://docs.astral.sh/uv/getting-started/installation/).
68
+
69
+ ```bash
70
+ uvx your-turn install
71
+ ```
72
+
73
+ `install` adds the Your Turn handoff instructions to the agent apps it finds, then starts the inbox. Restart those agent apps, then open [http://localhost:8787](http://localhost:8787).
74
+
75
+ To start the inbox later, run:
76
+
77
+ ```bash
78
+ uvx your-turn serve
79
+ ```
80
+
81
+ The server binds to loopback only (`127.0.0.1`). To check or remove the handoff, run `uvx your-turn doctor` or `uvx your-turn uninstall`.
82
+
83
+ `uvx your-turn` runs the [PyPI release](https://pypi.org/project/your-turn/), which is built from a Git tag by GitHub Actions. `uvx your-turn@latest` picks up a newer release. To pin a version, use `uvx your-turn@0.4.0`, or install straight from its tag with `uvx --from git+https://github.com/martin-gomola/your-turn@v0.4.0 your-turn`.
84
+
85
+ ### With Nix
86
+
87
+ The repository is a flake, with dependencies pinned in `flake.lock`. This starts the inbox without changing any agent app:
88
+
89
+ ```bash
90
+ nix run github:martin-gomola/your-turn
91
+ ```
92
+
93
+ Only `install` and `uninstall` change agent app folders. To see what `install` would touch, run `nix run github:martin-gomola/your-turn -- doctor` first. To skip it, add the handoff by hand from [docs/agents-snippet.md](./docs/agents-snippet.md), or print the Cursor rule with `nix run github:martin-gomola/your-turn -- cursor-rule`. `nix profile install github:martin-gomola/your-turn` puts `your-turn` on your `PATH`. To pin a release, use `github:martin-gomola/your-turn/v0.4.0`.
94
+
95
+ ## Privacy
96
+
97
+ - **Local only.** The server binds to `127.0.0.1`. It does not send session data anywhere.
98
+ - **Read-only session access.** Your Turn reads agent logs and Cursor's local chat database. It never edits them, starts or answers chats, or approves actions.
99
+ - **Small local state file.** `~/.your-turn/state.json` stores seen results and dismissed reminders so they stay in sync across browsers. The browser keeps a local backup.
100
+ - **No account or setup file.** Your Turn finds existing chats and leaves out sub-agents, headless runs, and abandoned chats.
101
+
102
+ ## Why install the handoff?
103
+
104
+ <details>
105
+ <summary>See how the handoff keeps finished chats out of Needs you</summary>
106
+
107
+ Agents often end with a polite question such as “Would you like me to update the docs?” Your Turn can read that as a question waiting on you, even when the work is done. The handoff asks the agent to end with an `Outcome` and a `Next step`, so the inbox can show a clear result.
108
+
109
+ Without the handoff:
110
+
111
+ ```text
112
+ I refactored the token refresh logic and ran pytest. All 12 tests passed.
113
+ Would you like me to update the docs or commit the changes?
114
+ ```
115
+
116
+ With the handoff:
117
+
118
+ ```text
119
+ I refactored the token refresh logic and ran pytest. All 12 tests passed.
120
+
121
+ **Outcome:** Token refresh is refactored and all 12 tests pass.
122
+
123
+ **Next step:** none
124
+ ```
125
+
126
+ If an agent needs a decision, answer, review, or approval, `Next step` names it. See the full format in [docs/configuration.md](./docs/configuration.md#your-turn-handoff).
127
+
128
+ </details>
129
+
130
+ ## Docs
131
+
132
+ | Guide | What it covers |
133
+ | --- | --- |
134
+ | [docs/harnesses.md](./docs/harnesses.md) | Data sources by OS, supported apps, inbox states, checks, and limits |
135
+ | [docs/configuration.md](./docs/configuration.md) | CLI commands and flags, app setup, handoff format, and development |
136
+ | [DESIGN.md](./DESIGN.md) | UI design contract and references |
137
+ | [docs/agents-snippet.md](./docs/agents-snippet.md) | Handoff format to add to agent instructions by hand |
138
+
139
+ ## Run from a clone
140
+
141
+ ```bash
142
+ git clone https://github.com/martin-gomola/your-turn.git
143
+ cd your-turn
144
+ make install
145
+ ```
146
+
147
+ `make install` sets up the handoff from this checkout and starts the inbox. Open [http://localhost:8787](http://localhost:8787). Later, from the same folder:
148
+
149
+ ```bash
150
+ make up # start the inbox in the background
151
+ make down # stop it
152
+ make doctor # check the handoff without changing it
153
+ make test # run the test suite
154
+ ```
155
+
156
+ See [docs/configuration.md](./docs/configuration.md#development) for all `make` targets and API routes.
157
+
158
+ ## License
159
+
160
+ [MIT](./LICENSE). Third-party icons and artwork are listed in [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md).
@@ -0,0 +1,47 @@
1
+ yourturn/__init__.py,sha256=42STGor_9nKYXumfeV5tiyD_M8VdcddX7CEexmibPBk,22
2
+ yourturn/app.py,sha256=Y_NY-hzuYXPOTzcZeBIrAMnVU0ylsw1pUUbHsAAf1rk,8027
3
+ yourturn/cli.py,sha256=5RbFnl2jtYT3bwW9s5KNhJucZAZ8lX4jL5vhQ7Jgvos,5925
4
+ yourturn/config.py,sha256=r1q2URbmin9ezIMz6VVV2pRBniLl-TQVDw8KcO0YifQ,562
5
+ yourturn/engine.py,sha256=nV9QiUGWfszQSUYhW_qny--ZGTo41ls9yKx7gmBTSfE,36049
6
+ yourturn/heuristics.py,sha256=jll8Hboi0BHns1kalvdwb1mmzXwR4Mj6FfLmXESayks,17055
7
+ yourturn/install.py,sha256=g5xalQVA-dZxLgMMxZEjqtKxk0vXUeLJPAfCrOnCAxg,22483
8
+ yourturn/model.py,sha256=y5Lf-Ion6VjlTSOMnS9rO9blXkmi1hgpbojfh_TS2ZI,3514
9
+ yourturn/state.py,sha256=1OQBV4MsippHRL8kL8akI0TpDKFc3qKJ6JYt4xUpJSM,3178
10
+ yourturn/harness/__init__.py,sha256=aYV3nQXzYE6bYHjXpVkPhRe3CmgG9CIgJGXYz3_Yt08,96
11
+ yourturn/harness/base.py,sha256=4_UpyUIGFi8r7OX4qv2_NoQLkJJUAUZ28DPI_QlR4Qo,9151
12
+ yourturn/harness/claude.py,sha256=v_3EjdyTt-4ThxQGp4TAxuglYCQvN0LpLFHcVZJlUvs,25450
13
+ yourturn/harness/codex.py,sha256=h0c4q1-8vPC0HZJs1HqWDngz18P2QPd7M4xVwB0OzRE,19249
14
+ yourturn/harness/cursor.py,sha256=kxNgR91sScUeWu0qGz7OMwPwHOilAfDEKPlDu_a32iM,20860
15
+ yourturn/static/app-icon-dark-180.png,sha256=tz5ZLCSEOXXkKcrGQe1IVbH6oCmDcHzB8MLZjNa2YC0,5952
16
+ yourturn/static/app-icon-dark-192.png,sha256=chYhyf3_asjZpAt26TZOvwHynE40Rl7JDW-nlg-zFg4,5900
17
+ yourturn/static/app-icon-dark-512.png,sha256=PzrvQPq-v6ftBakIE2wYQ-QLc9Bzme4vYy-p2COmtpc,16974
18
+ yourturn/static/app-icon-light-180.png,sha256=NgsG8xA-ZVTo0mDmerJKpQFg9yUyfOfbtqYc0dAl9aQ,6183
19
+ yourturn/static/app-icon-light-192.png,sha256=ClHceUmDlYTuxohW3hvoYpiKj7tZHQAd4HIEUnfgcuE,6104
20
+ yourturn/static/app-icon-light-512.png,sha256=D2HkURz6iCpUy7E9WK8VTEHQrHepAK4-hXwUZTQbPfU,16824
21
+ yourturn/static/app.css,sha256=UJHCkGI8v_VGkN44UPdIBYO9q8iUxv2LSttUFlZQzrA,68021
22
+ yourturn/static/app.js,sha256=RmqI18A2xeD_C77yADwVUmQtAzbxL27jaURj-BwRDMg,80538
23
+ yourturn/static/brand-leaf.webp,sha256=qeBru_ZQ-3NEwFo1s6iH2zy3AJfdWRCnR1mUxv8B4L0,4000
24
+ yourturn/static/favicon-alert.ico,sha256=VNhxt-ftKn90sfHABzy6mOlsJFoK0MDObjDmK8F_5Cw,3374
25
+ yourturn/static/favicon-dark-alert.ico,sha256=KmImWO6EXlhb-We5IkcByAJe6bLROyNCcBF7eoDMsGg,3199
26
+ yourturn/static/favicon-dark.ico,sha256=OIJjv94wmectHgKHG9051MNhLFoLifK_bhcEOx-P4fw,3200
27
+ yourturn/static/favicon.ico,sha256=1ov04oH-q01L027axxX8mIHWB3xi94hKLNS2HDO16HI,3362
28
+ yourturn/static/header-dark.webp,sha256=KxOWfTbtcMXviKOik-FxG_0VSAEbra__KC4_yVCWNLs,143772
29
+ yourturn/static/header-light.webp,sha256=0N5rlp5AkmRkLbUQdtwEEkbJEccLJNcP3t3ePwzdTrw,77474
30
+ yourturn/static/icon-alert.svg,sha256=RQSHSZE4oNGjX5PgjzpefjocyiKeK8P9Vlo0yxE-zEI,750
31
+ yourturn/static/icon-dark-alert.svg,sha256=TLtZ2_nDFgezM3zplHwoP2aZGr6zcvViP5pmgtY6W30,820
32
+ yourturn/static/icon-dark.svg,sha256=cWzzh3ctWqyLNIT9YbPK_b1I0s1pOsgHBNEsLKQUhFw,822
33
+ yourturn/static/icon.svg,sha256=2YHRFO9iyxPydy4KQvwbzEtF7a8JvqQizEJxFLGWwNY,756
34
+ yourturn/static/index.html,sha256=eqMHVRX5Q89XbLV0uFX7SNITP8o170fScLHS_uvGW-c,28728
35
+ yourturn/static/navigation.js,sha256=zLeERr_T-WxFCv5Xn0eongkZpKlngxzoKIXqHFq54h8,1232
36
+ yourturn/static/state-sync.js,sha256=VE_WKM6llyOzxFO0GQKYM8V8zA59-rPDRZVWlo0c2-4,4668
37
+ yourturn/static/theme.js,sha256=3jtMaPGvYuFIKxv06ilT_mF82BHW0ImTc19vZklaMPE,1572
38
+ yourturn/static/tokens.css,sha256=J54-W-F9wIXrp6Q6oIgY3l-MQnJuKXXhj8i3T6GZB-w,4507
39
+ yourturn/plugin/.codex-plugin/plugin.json,sha256=IEBace7vXtENoqLucMLCsmCIp06lIfI-zZH21uUPC80,1111
40
+ yourturn/plugin/assets/your-turn-dark.png,sha256=PzrvQPq-v6ftBakIE2wYQ-QLc9Bzme4vYy-p2COmtpc,16974
41
+ yourturn/plugin/assets/your-turn-light.png,sha256=D2HkURz6iCpUy7E9WK8VTEHQrHepAK4-hXwUZTQbPfU,16824
42
+ yourturn/plugin/skills/your-turn-handoff/SKILL.md,sha256=JzA-PaYyz_Z56muGDDcmzsoyTCjK-hd-igVhprFo5GQ,6025
43
+ your_turn-0.4.0.dist-info/METADATA,sha256=Ems7-nD_aj4so4kv7nOZ40PvJUhvB8nNapk71TkoGe4,6976
44
+ your_turn-0.4.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
45
+ your_turn-0.4.0.dist-info/entry_points.txt,sha256=xL0nm-Z4nS9mpB6Uwu2a1jT3gxLYju-a7tNJzFTQYWw,77
46
+ your_turn-0.4.0.dist-info/licenses/LICENSE,sha256=g7_Dxduwq94sW6Oir1CbSjuJCVpIfg8EQWtdS3QL0Y4,1070
47
+ your_turn-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ your-turn = yourturn.cli:main
3
+ yourturn = yourturn.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martin Gomola
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
yourturn/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
yourturn/app.py ADDED
@@ -0,0 +1,158 @@
1
+ """Loopback-only Your Turn server: an inbox of agent chats (Codex, Claude Code, Cursor) and a per-turn summary of each."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from collections.abc import AsyncIterator, Callable
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import urlsplit
12
+
13
+ from fastapi import FastAPI, HTTPException, Request, Response
14
+ from fastapi.responses import HTMLResponse, StreamingResponse
15
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
16
+
17
+ from yourturn import __version__
18
+ from yourturn.config import DEFAULT_HISTORY_DAYS
19
+ from yourturn.engine import SessionHub
20
+ from yourturn.model import UUID, Harness, SessionKey
21
+ from yourturn.state import InboxState
22
+
23
+ LOOPBACK_HOSTS = ["127.0.0.1", "localhost", "[::1]"]
24
+ POLL_SECONDS = 1.0
25
+ HEARTBEAT_SECONDS = 15.0
26
+ PAGE_POLICY = (
27
+ "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; "
28
+ "base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
29
+ )
30
+ ASSET_TYPES = {".css": "text/css", ".js": "text/javascript", ".svg": "image/svg+xml", ".webp": "image/webp", ".png": "image/png", ".ico": "image/x-icon"}
31
+
32
+
33
+ def _static(name: str) -> str:
34
+ return files("yourturn").joinpath("static", name).read_text(encoding="utf-8")
35
+
36
+
37
+ def create_app(
38
+ codex_home: Path, poll_seconds: float = POLL_SECONDS, should_stop: Callable[[], bool] = lambda: False,
39
+ history_days: int = DEFAULT_HISTORY_DAYS, claude_home: Path | None = None,
40
+ cursor_home: Path | None = None, cursor_user_dir: Path | None = None, state_path: Path | None = None,
41
+ ) -> FastAPI:
42
+ app = FastAPI(title="Your Turn", docs_url=None, redoc_url=None, openapi_url=None)
43
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=LOOPBACK_HOSTS)
44
+ hub = SessionHub.for_codex_home(codex_home, claude_home=claude_home, cursor_home=cursor_home, cursor_user_dir=cursor_user_dir)
45
+ state = InboxState(state_path) if state_path else InboxState()
46
+
47
+ @app.middleware("http")
48
+ async def security_headers(request: Request, call_next: Any) -> Response:
49
+ response: Response = await call_next(request)
50
+ response.headers["Content-Security-Policy"] = PAGE_POLICY
51
+ response.headers["X-Content-Type-Options"] = "nosniff"
52
+ response.headers["Referrer-Policy"] = "no-referrer"
53
+ response.headers["Cache-Control"] = "no-store"
54
+ return response
55
+
56
+ served = {Harness(adapter.name) for adapter in hub.adapters}
57
+
58
+ def checked(thread: str) -> SessionKey:
59
+ """Resolve `<harness>:<uuid>` or a bare `<uuid>` (Codex, same rule as `SessionHub.detail`) to a session key.
60
+
61
+ Malformed ids -> 400; a well-formed key for a harness with no session source -> 404.
62
+ """
63
+ try:
64
+ key = SessionKey.parse(thread)
65
+ except ValueError:
66
+ raise HTTPException(status_code=400, detail="thread must be a session key or Codex thread id") from None
67
+ if key.harness not in served:
68
+ raise HTTPException(status_code=404, detail="no session source for this harness")
69
+ # Codex thread ids, Claude Code session ids and Cursor composer ids are all UUIDs.
70
+ if not UUID.match(key.native_id):
71
+ raise HTTPException(status_code=400, detail="thread must be a session key or Codex thread id")
72
+ return key
73
+
74
+ @app.get("/", response_class=HTMLResponse)
75
+ def index() -> str:
76
+ return _static("index.html").replace("{{APP_VERSION}}", __version__).replace("{{HISTORY_DAYS}}", str(history_days))
77
+
78
+ @app.get("/assets/{name}")
79
+ def asset(name: str) -> Response:
80
+ media_type = ASSET_TYPES.get(Path(name).suffix)
81
+ if media_type is None or name not in {entry.name for entry in files("yourturn").joinpath("static").iterdir()}:
82
+ raise HTTPException(status_code=404)
83
+ return Response(files("yourturn").joinpath("static", name).read_bytes(), media_type=media_type)
84
+
85
+ @app.get("/api/inbox")
86
+ def inbox() -> list[dict[str, Any]]:
87
+ return hub.inbox_dicts(history_days)
88
+
89
+ @app.get("/api/threads/{thread}")
90
+ def thread_state(thread: str) -> dict[str, Any]:
91
+ data = hub.detail_dict(checked(thread))
92
+ if data is None:
93
+ raise HTTPException(status_code=404, detail="no session file for this thread")
94
+ return data
95
+
96
+ @app.post("/api/state", status_code=204)
97
+ async def write_state(request: Request) -> None:
98
+ """Mark results seen or reminders dismissed for every browser; streams push the change to the others."""
99
+ # JSON from this page only: a cross-site form cannot send JSON, and a cross-site fetch carries its Origin.
100
+ origin = request.headers.get("origin")
101
+ if not request.headers.get("content-type", "").startswith("application/json") or (
102
+ origin and urlsplit(origin).hostname not in {"127.0.0.1", "localhost", "::1"}):
103
+ raise HTTPException(status_code=403)
104
+ try:
105
+ body = await request.json()
106
+ except ValueError:
107
+ raise HTTPException(status_code=400) from None
108
+
109
+ def entries(name: str, nullable: bool) -> dict[str, Any]:
110
+ values = body.get(name) or {} if isinstance(body, dict) else None
111
+ if not isinstance(values, dict) or not all(isinstance(v, str) or (nullable and v is None) for v in values.values()):
112
+ raise HTTPException(status_code=400, detail=f"{name} must map chat keys to strings")
113
+ return {str(checked(key)): value for key, value in values.items()}
114
+
115
+ seen, dismissed = entries("seen", False), entries("dismissed", True)
116
+ stamps = body.get("dismissed_at", {})
117
+ if not isinstance(stamps, dict) or not all(type(v) is int and 0 <= v <= 2**53 - 1 for v in stamps.values()):
118
+ raise HTTPException(status_code=400, detail="dismissed_at must map chat keys to timestamps")
119
+ stamps = {str(checked(key)): value for key, value in stamps.items()}
120
+ if not stamps.keys() <= dismissed.keys():
121
+ raise HTTPException(status_code=400, detail="timestamps require a matching dismissal or restore")
122
+ keep = {row["key"] for row in await asyncio.to_thread(hub.inbox_dicts, history_days)}
123
+ await asyncio.to_thread(state.update, seen, dismissed, keep, stamps)
124
+
125
+ @app.get("/api/stream")
126
+ async def stream(request: Request, thread: str | None = None) -> StreamingResponse:
127
+ key: SessionKey | None = checked(thread) if thread else None
128
+
129
+ def current(last: str | None) -> tuple[str, Any]:
130
+ """Blocking file reads; called off the event loop so one large chat cannot stall every stream."""
131
+ if key is not None:
132
+ data = hub.detail_dict(key)
133
+ return (f"{data['version']}:{data['quiet']}" if data else "missing"), data
134
+ if last is None:
135
+ # Warm the entry cache so the first marker already knows which chats are quiet.
136
+ hub.inbox_dicts(history_days)
137
+ marker = hub.inbox_fingerprint(history_days)
138
+ return marker, hub.inbox_dicts(history_days) if marker != last else None
139
+
140
+ async def events() -> AsyncIterator[str]:
141
+ last, idle, shared = None, 0.0, -1
142
+ while not should_stop() and not await request.is_disconnected():
143
+ if state.version != shared:
144
+ shared = state.version
145
+ yield f"event: state\ndata: {json.dumps(state.snapshot())}\n\n"
146
+ marker, data = await asyncio.to_thread(current, last)
147
+ if marker != last:
148
+ last, idle = marker, 0.0
149
+ yield f"event: {'thread' if thread else 'inbox'}\ndata: {json.dumps(data)}\n\n"
150
+ elif idle >= HEARTBEAT_SECONDS:
151
+ idle = 0.0
152
+ yield ": heartbeat\n\n"
153
+ await asyncio.sleep(poll_seconds)
154
+ idle += poll_seconds
155
+
156
+ return StreamingResponse(events(), media_type="text/event-stream")
157
+
158
+ return app
yourturn/cli.py ADDED
@@ -0,0 +1,122 @@
1
+ """`your-turn` (or `your-turn serve`) runs the dashboard; `your-turn install` sets up the handoff for every
2
+ agent app found (globally, never in a project), then, run in a terminal, starts the dashboard unless it is
3
+ already running; `your-turn uninstall` removes it; `your-turn
4
+ doctor` reports whether the installed handoff is current, without changing anything; `your-turn cursor-rule`
5
+ prints the Cursor User Rule to paste into Cursor's settings."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import urllib.request
14
+ from pathlib import Path
15
+
16
+ from yourturn.install import APPS, detect, doctor, install, rule_text, uninstall
17
+
18
+ SERVE = "uvx your-turn serve"
19
+ PORT = 8787
20
+
21
+
22
+ def _url(port: int) -> str:
23
+ """The address to show: the server binds only 127.0.0.1, which `localhost` reaches and is easier to type."""
24
+ return f"http://localhost:{port}"
25
+
26
+
27
+ def _interactive() -> bool:
28
+ return sys.stdin.isatty() and sys.stdout.isatty()
29
+
30
+
31
+ def _inbox_running(port: int) -> bool:
32
+ """Whether Your Turn already answers on the port, as `make up` checks it."""
33
+ try:
34
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/inbox", timeout=1):
35
+ return True
36
+ except OSError:
37
+ return False
38
+
39
+
40
+ def main(argv: list[str] | None = None) -> int | None:
41
+ argv = sys.argv[1:] if argv is None else argv
42
+ # No subcommand, or only serve options: serve.
43
+ if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help")):
44
+ argv = ["serve", *argv]
45
+ parser = argparse.ArgumentParser(prog="your-turn")
46
+ commands = parser.add_subparsers(dest="command", required=True)
47
+ for name, help_text in (("serve", "Run the dashboard on localhost"),
48
+ ("install", "Set up the handoff for Codex, Claude Code and Cursor, then start the dashboard"),
49
+ ("uninstall", "Remove the handoff that install set up"),
50
+ ("doctor", "Check, without changing anything, whether the installed handoff is current")):
51
+ command = commands.add_parser(name, help=help_text)
52
+ if name == "serve":
53
+ command.add_argument("--port", type=int, default=PORT)
54
+ command.add_argument("--cursor-user-dir", type=Path, default=None,
55
+ help="Cursor user data folder holding globalStorage/state.vscdb (default: per OS)")
56
+ else:
57
+ command.add_argument("--harness", choices=[*APPS, "all"], default=None,
58
+ help="Only this app, or all three (default: every app whose folder exists)")
59
+ command.add_argument("--codex-home", type=Path,
60
+ default=Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")))
61
+ command.add_argument("--claude-home", type=Path,
62
+ default=Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")))
63
+ command.add_argument("--cursor-home", type=Path, default=Path.home() / ".cursor",
64
+ help="Cursor home holding projects/*/agent-transcripts (default: ~/.cursor)")
65
+ commands.add_parser("cursor-rule", help="Print the Cursor User Rule to paste into Customize → Rules")
66
+ args = parser.parse_args(argv)
67
+
68
+ if args.command == "cursor-rule":
69
+ print(rule_text("cursor"), end="", flush=True)
70
+ return 0
71
+
72
+ if args.command in ("install", "uninstall", "doctor"):
73
+ homes = {"codex": args.codex_home, "claude": args.claude_home, "cursor": args.cursor_home}
74
+ harnesses = list(APPS) if args.harness == "all" else [args.harness] if args.harness else detect(homes)
75
+ if args.command == "doctor":
76
+ return doctor(harnesses, homes)
77
+ try:
78
+ done = (install if args.command == "install" else uninstall)(harnesses, homes)
79
+ except subprocess.CalledProcessError as error:
80
+ parser.exit(1, f"Plugin install failed: {' '.join(error.cmd)}\n")
81
+ if args.command == "uninstall":
82
+ return
83
+ names = [APPS[harness] for harness in done]
84
+ apps = ", ".join(names[:-1]) + f" and {names[-1]}" if len(names) > 1 else "".join(names)
85
+ restart = f"Restart {apps} to load the handoff" if names else ""
86
+ # Run from a script, or with input or output redirected, install only sets up and exits.
87
+ if not _interactive():
88
+ start = f"start the inbox with: {SERVE} (or `make up` in a clone)"
89
+ print(f"\n→ {restart}, then {start}" if restart else f"\n→ {start[0].upper()}{start[1:]}", flush=True)
90
+ return
91
+ if restart:
92
+ print(f"\n→ {restart}.", flush=True)
93
+ if _inbox_running(PORT):
94
+ print(f"→ Your Turn is already running at {_url(PORT)}", flush=True)
95
+ return
96
+ print(f"→ Next time, start the inbox with: {SERVE}", flush=True)
97
+ args.port, args.cursor_user_dir = PORT, None
98
+
99
+ import uvicorn
100
+
101
+ from yourturn.app import create_app
102
+
103
+ from yourturn.config import load_history_days
104
+
105
+ try:
106
+ history_days = load_history_days()
107
+ except (OSError, ValueError) as error:
108
+ parser.error(f"Cannot read Your Turn config: {error}")
109
+
110
+ print(f"Starting Your Turn at {_url(args.port)}. Press Ctrl+C to stop it.", flush=True)
111
+ try:
112
+ uvicorn.run(create_app(args.codex_home, history_days=history_days, claude_home=args.claude_home,
113
+ cursor_home=args.cursor_home, cursor_user_dir=args.cursor_user_dir),
114
+ host="127.0.0.1", port=args.port, log_level="warning")
115
+ except SystemExit as error:
116
+ if error.code not in (None, 0):
117
+ print(
118
+ f"Could not start Your Turn. If it is already running, open {_url(args.port)} "
119
+ f"in your browser. Otherwise, try another port: your-turn serve --port 8788.",
120
+ file=sys.stderr,
121
+ )
122
+ raise
yourturn/config.py ADDED
@@ -0,0 +1,17 @@
1
+ """Local dashboard configuration, loaded when the server starts."""
2
+
3
+ from pathlib import Path
4
+ import tomllib
5
+
6
+ DEFAULT_HISTORY_DAYS = 14
7
+ CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.toml"
8
+
9
+
10
+ def load_history_days(path: Path = CONFIG_PATH) -> int:
11
+ if not path.exists():
12
+ return DEFAULT_HISTORY_DAYS
13
+ with path.open("rb") as handle:
14
+ days = tomllib.load(handle).get("history_days", DEFAULT_HISTORY_DAYS)
15
+ if type(days) is not int or days < 1:
16
+ raise ValueError("history_days must be a positive integer")
17
+ return days