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.
- your_turn-0.4.0.dist-info/METADATA +160 -0
- your_turn-0.4.0.dist-info/RECORD +47 -0
- your_turn-0.4.0.dist-info/WHEEL +4 -0
- your_turn-0.4.0.dist-info/entry_points.txt +3 -0
- your_turn-0.4.0.dist-info/licenses/LICENSE +21 -0
- yourturn/__init__.py +1 -0
- yourturn/app.py +158 -0
- yourturn/cli.py +122 -0
- yourturn/config.py +17 -0
- yourturn/engine.py +814 -0
- yourturn/harness/__init__.py +1 -0
- yourturn/harness/base.py +278 -0
- yourturn/harness/claude.py +522 -0
- yourturn/harness/codex.py +476 -0
- yourturn/harness/cursor.py +460 -0
- yourturn/heuristics.py +327 -0
- yourturn/install.py +467 -0
- yourturn/model.py +130 -0
- yourturn/plugin/.codex-plugin/plugin.json +35 -0
- yourturn/plugin/assets/your-turn-dark.png +0 -0
- yourturn/plugin/assets/your-turn-light.png +0 -0
- yourturn/plugin/skills/your-turn-handoff/SKILL.md +87 -0
- yourturn/state.py +65 -0
- yourturn/static/app-icon-dark-180.png +0 -0
- yourturn/static/app-icon-dark-192.png +0 -0
- yourturn/static/app-icon-dark-512.png +0 -0
- yourturn/static/app-icon-light-180.png +0 -0
- yourturn/static/app-icon-light-192.png +0 -0
- yourturn/static/app-icon-light-512.png +0 -0
- yourturn/static/app.css +809 -0
- yourturn/static/app.js +1703 -0
- yourturn/static/brand-leaf.webp +0 -0
- yourturn/static/favicon-alert.ico +0 -0
- yourturn/static/favicon-dark-alert.ico +0 -0
- yourturn/static/favicon-dark.ico +0 -0
- yourturn/static/favicon.ico +0 -0
- yourturn/static/header-dark.webp +0 -0
- yourturn/static/header-light.webp +0 -0
- yourturn/static/icon-alert.svg +9 -0
- yourturn/static/icon-dark-alert.svg +10 -0
- yourturn/static/icon-dark.svg +10 -0
- yourturn/static/icon.svg +9 -0
- yourturn/static/index.html +342 -0
- yourturn/static/navigation.js +21 -0
- yourturn/static/state-sync.js +114 -0
- yourturn/static/theme.js +46 -0
- 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
|
+

|
|
20
|
+

|
|
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,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
|