codex-telegram 0.5.9__tar.gz → 0.6.1__tar.gz

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 (102) hide show
  1. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/PKG-INFO +12 -1
  2. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/README.md +11 -0
  3. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/TELEGRAM.md +19 -2
  4. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/WEB.md +48 -13
  5. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex_telegram/__init__.py +1 -1
  6. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex_telegram.egg-info/PKG-INFO +12 -1
  7. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex_telegram.egg-info/SOURCES.txt +4 -2
  8. codex_telegram-0.6.1/tests/test_project_config.py +42 -0
  9. codex_telegram-0.6.1/tests/test_project_routes.py +59 -0
  10. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_web_server_local_command.py +44 -0
  11. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/utils/config.py +101 -1
  12. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/routes.py +59 -12
  13. codex_telegram-0.6.1/web/static/dist/assets/index-0u8Hjl9v.js +256 -0
  14. codex_telegram-0.6.1/web/static/dist/assets/index-Bh3KHDwf.css +1 -0
  15. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/static/dist/index.html +2 -2
  16. codex_telegram-0.5.9/web/static/dist/assets/index-DIOICBNe.js +0 -251
  17. codex_telegram-0.5.9/web/static/dist/assets/index-DW1AcoLg.css +0 -1
  18. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/LICENSE +0 -0
  19. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/MANIFEST.in +0 -0
  20. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/app_runtime/__init__.py +0 -0
  21. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/app_runtime/bootstrap.py +0 -0
  22. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/app_runtime/telegram_app.py +0 -0
  23. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/app_runtime/web_server.py +0 -0
  24. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/bot/__init__.py +0 -0
  25. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/bot/callbacks.py +0 -0
  26. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/bot/features_ui.py +0 -0
  27. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/bot/handlers.py +0 -0
  28. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/bot/keyboard.py +0 -0
  29. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/bot/projects_ui.py +0 -0
  30. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/bot/skills_ui.py +0 -0
  31. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/bot/thread_ui.py +0 -0
  32. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/__init__.py +0 -0
  33. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/approval_flow.py +0 -0
  34. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/approval_guardian.py +0 -0
  35. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/client.py +0 -0
  36. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/client_pool.py +0 -0
  37. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/collaboration_mode.py +0 -0
  38. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/command_router/__init__.py +0 -0
  39. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/command_router/common.py +0 -0
  40. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/command_router/context.py +0 -0
  41. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/command_router/contracts.py +0 -0
  42. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/command_router/core.py +0 -0
  43. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/command_router/projects.py +0 -0
  44. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/command_router/review.py +0 -0
  45. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/command_router/system.py +0 -0
  46. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/command_router/threads.py +0 -0
  47. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/commands.py +0 -0
  48. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/event_forwarding.py +0 -0
  49. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/events.py +0 -0
  50. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex/protocol.py +0 -0
  51. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex_telegram/cli.py +0 -0
  52. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex_telegram.egg-info/dependency_links.txt +0 -0
  53. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex_telegram.egg-info/entry_points.txt +0 -0
  54. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex_telegram.egg-info/requires.txt +0 -0
  55. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/codex_telegram.egg-info/top_level.txt +0 -0
  56. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/conf.toml.example +0 -0
  57. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/main.py +0 -0
  58. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/models/__init__.py +0 -0
  59. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/models/state.py +0 -0
  60. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/models/telegram_bridge.py +0 -0
  61. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/models/thread.py +0 -0
  62. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/models/user.py +0 -0
  63. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/pyproject.toml +0 -0
  64. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/setup.cfg +0 -0
  65. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_approval_guardian.py +0 -0
  66. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_approval_policy.py +0 -0
  67. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_bootstrap_update_check.py +0 -0
  68. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_callbacks_results.py +0 -0
  69. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_cli_entrypoint.py +0 -0
  70. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_codex_client.py +0 -0
  71. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_codex_client_pool.py +0 -0
  72. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_command_router.py +0 -0
  73. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_guardian_config.py +0 -0
  74. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_handlers_results.py +0 -0
  75. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_keyboard.py +0 -0
  76. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_main_approval_flow.py +0 -0
  77. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_normalize.py +0 -0
  78. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_pypi_check.py +0 -0
  79. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_single_instance.py +0 -0
  80. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_telegram_bot_config.py +0 -0
  81. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_thread_history.py +0 -0
  82. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/tests/test_web_runtime.py +0 -0
  83. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/utils/__init__.py +0 -0
  84. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/utils/approval_policy.py +0 -0
  85. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/utils/local_command.py +0 -0
  86. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/utils/logger.py +0 -0
  87. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/utils/normalize.py +0 -0
  88. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/utils/pypi_check.py +0 -0
  89. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/utils/single_instance.py +0 -0
  90. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/utils/web_config.py +0 -0
  91. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/__init__.py +0 -0
  92. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/dependencies.py +0 -0
  93. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/runtime.py +0 -0
  94. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/server.py +0 -0
  95. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/static/dist/assets/codex-telegram-icon-black.svg +0 -0
  96. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/static/dist/assets/codex-telegram-icon-ivory.svg +0 -0
  97. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/static/dist/assets/codex-telegram-login-image.png +0 -0
  98. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/static/dist/favicon.ico +0 -0
  99. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/telegram_sync.py +0 -0
  100. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/telegram_thread_selection.py +0 -0
  101. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/thread_history.py +0 -0
  102. {codex_telegram-0.5.9 → codex_telegram-0.6.1}/web/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-telegram
3
- Version: 0.5.9
3
+ Version: 0.6.1
4
4
  Summary: Bridge for controlling the Codex App Server from Telegram and Web UI
5
5
  Author: heungtae
6
6
  License: Apache License
@@ -382,6 +382,17 @@ cp conf.toml.example ~/.config/codex-telegram/conf.toml
382
382
  ./run_build_and_main.sh
383
383
  ```
384
384
 
385
+ ## Publishing a Release
386
+
387
+ 1. Update `codex_telegram/__init__.py` (`__version__`), validate the changes, and push the commits.
388
+ 2. In GitHub Releases, create a draft with a matching tag (for example, `v0.6.0` for version `0.6.0`) targeting the intended commit. The commit must contain `.github/workflows/pypi-publish.yml` with the release trigger.
389
+ 3. Write the release notes and publish the Release. Drafts and tag pushes alone do not deploy.
390
+ 4. Wait for **Publish release to PyPI** to succeed: it builds the Web UI, builds/checks the wheel and sdist (including UI assets), publishes to PyPI, then attaches the same distributions and `SHA256SUMS` to the Release.
391
+
392
+ Configure `PYPI_API_TOKEN` as a repository secret or a secret on the `pypi` environment before publishing. Existing environment protection rules still apply; remove required reviewers from that environment if fully automatic publishing is desired. Published prereleases also trigger this workflow, so use a matching Python prerelease version such as `0.6.1rc1` / `v0.6.1rc1`.
393
+
394
+ A published GitHub Release alone does not mean PyPI deployment succeeded; check the workflow result. If attachment fails after PyPI publishing succeeds, rerun only failed jobs to attach the retained files without republishing. For code changes after a successful PyPI upload, use a new version and Release.
395
+
385
396
  ## Documentation
386
397
 
387
398
  | File | Description |
@@ -157,6 +157,17 @@ cp conf.toml.example ~/.config/codex-telegram/conf.toml
157
157
  ./run_build_and_main.sh
158
158
  ```
159
159
 
160
+ ## Publishing a Release
161
+
162
+ 1. Update `codex_telegram/__init__.py` (`__version__`), validate the changes, and push the commits.
163
+ 2. In GitHub Releases, create a draft with a matching tag (for example, `v0.6.0` for version `0.6.0`) targeting the intended commit. The commit must contain `.github/workflows/pypi-publish.yml` with the release trigger.
164
+ 3. Write the release notes and publish the Release. Drafts and tag pushes alone do not deploy.
165
+ 4. Wait for **Publish release to PyPI** to succeed: it builds the Web UI, builds/checks the wheel and sdist (including UI assets), publishes to PyPI, then attaches the same distributions and `SHA256SUMS` to the Release.
166
+
167
+ Configure `PYPI_API_TOKEN` as a repository secret or a secret on the `pypi` environment before publishing. Existing environment protection rules still apply; remove required reviewers from that environment if fully automatic publishing is desired. Published prereleases also trigger this workflow, so use a matching Python prerelease version such as `0.6.1rc1` / `v0.6.1rc1`.
168
+
169
+ A published GitHub Release alone does not mean PyPI deployment succeeded; check the workflow result. If attachment fails after PyPI publishing succeeds, rerun only failed jobs to attach the retained files without republishing. For code changes after a successful PyPI upload, use a new version and Release.
170
+
160
171
  ## Documentation
161
172
 
162
173
  | File | Description |
@@ -24,14 +24,17 @@ Execute Codex commands using slash commands in any chat with the bot:
24
24
  | `/compact <id>` | thread/compact/start | Compact conversation history |
25
25
  | `/rollback <n>` | thread/rollback | Roll back last N turns |
26
26
  | `/interrupt` | turn/interrupt | Interrupt running turn |
27
- | `/review` | review/start | Start code review |
27
+ | `/review [uncommittedChanges\|baseBranch\|commit\|custom]` | review/start | Start code review |
28
28
  | `/exec <cmd>` | command/exec | Run command |
29
29
  | `/models` | model/list | List available models |
30
30
  | `/features` | experimentalFeature/list + command/exec | Show beta features |
31
31
  | `/guardian` | local config | Show guardian summary |
32
32
  | `/modes` | collaborationMode/list | List collaboration modes |
33
33
  | `/collab` | collaborationMode/list | List collaboration modes |
34
- | `/skills` | skills/list | List skills |
34
+ | `/mode [toggle\|plan\|build]` | - | Show or set collaboration mode |
35
+ | `/plan` | - | Switch collaboration mode to plan |
36
+ | `/build` | - | Switch collaboration mode to build (default) |
37
+ | `/skills [cwd]` | skills/list | List skills |
35
38
  | `/apps` | app/list | List apps |
36
39
  | `/mcp` | mcpServerStatus/list | List MCP servers |
37
40
  | `/config` | config/read | Read configuration |
@@ -58,6 +61,7 @@ Configure which events to receive via `telegram.forwarding.app_server_event_allo
58
61
  - Archive old threads to keep your list clean
59
62
  - Compact conversation history to save context
60
63
  - Rollback turns to undo mistakes
64
+ - Start code reviews with `/review [uncommittedChanges|baseBranch|commit|custom]`
61
65
 
62
66
  ### Project Switching
63
67
 
@@ -68,6 +72,19 @@ Manage multiple projects:
68
72
  3. Use `/project <name>` to switch
69
73
  4. Each project maintains separate thread history
70
74
 
75
+ ### Collaboration Mode
76
+
77
+ Control how Codex approaches tasks:
78
+
79
+ | Command | Description |
80
+ |---------|-------------|
81
+ | `/mode` | Show current mode or set to `toggle`, `plan`, or `build` |
82
+ | `/plan` | Switch to plan mode (analyze, don't execute) |
83
+ | `/build` | Switch to build mode (execute changes, default) |
84
+ | `/modes` / `/collab` | List available collaboration modes |
85
+
86
+ Modes are session-local and persist per user. The current mode is shown in the welcome message and when switching projects.
87
+
71
88
  ## Configuration
72
89
 
73
90
  ### Basic Setup
@@ -20,6 +20,7 @@ A ChatGPT-style web interface for interacting with Codex App Server. Provides re
20
20
  - **Thread tabs**: Multiple conversations per project, each with independent chat state
21
21
  - **Thread states**: Visual indicators for idle, running, completed, failed, or cancelled
22
22
  - **Unread notifications**: Badge indicators for new completions
23
+ - **Project management**: Use the project menu → Modify Project to edit its name/path. The key remains fixed to preserve chat associations. Add/Modify Project includes a Default project checkbox. The default project is marked D and its Remove menu is disabled; choose another default before removing it. Changing the default updates the shared configuration without switching existing open chats.
23
24
  - **Persistent state**: Thread and project tabs preserved across sessions
24
25
 
25
26
  ### Workspace Browser
@@ -47,6 +48,7 @@ The Web UI receives live updates via SSE for:
47
48
  | `approval_required` | Guardian/approval needed |
48
49
  | `file_change` | File modifications with diffs |
49
50
  | `app_event` | Tool execution events |
51
+ | `user_message` | User message sent |
50
52
 
51
53
  ### Authentication
52
54
 
@@ -96,29 +98,39 @@ Guardian provides automated safety checks before approval decisions:
96
98
 
97
99
  | Shortcut | Action |
98
100
  |----------|--------|
99
- | `Ctrl/Cmd + Enter` | Send message |
101
+ | `Ctrl/Cmd + Enter` | Send message (or Shift+Enter for newline) |
100
102
  | `Tab` | Toggle Build/Plan mode |
103
+ | `Escape` | Close modals / cancel project picker |
101
104
 
102
- #### Alt Key Shortcuts
105
+ #### Alt Key Shortcuts (when not in input)
103
106
 
104
107
  | Shortcut | Action |
105
108
  |----------|--------|
106
- | `Alt + N` | New chat |
107
- | `Alt + T` | New thread tab |
108
- | `Alt + W` | Close current thread |
109
- | `Alt + P` | Open project picker |
110
- | `Alt + [` | Previous thread |
111
- | `Alt + ]` | Next thread |
112
- | `Alt + 1-9` | Switch to thread 1-9 |
113
- | `Alt + V` | Toggle workspace panel |
109
+ | `Alt + N` | New chat (focus composer, start thread) |
110
+ | `Alt + T` | New thread tab (start thread) |
111
+ | `Alt + W` | Close current thread tab |
112
+ | `Alt + P` | Open project picker modal |
113
+ | `Alt + [` | Previous thread in project |
114
+ | `Alt + ]` | Next thread in project |
115
+ | `Alt + 1-9` | Switch to thread 1-9 in current project |
116
+ | `Alt + V` | Toggle workspace panel (compact layout) |
114
117
 
115
- #### Mode
118
+ #### Ctrl/Cmd Key Shortcuts (when not in input)
116
119
 
117
120
  | Shortcut | Action |
118
121
  |----------|--------|
119
122
  | `Ctrl/Cmd + B` | Switch to Build mode |
120
123
  | `Ctrl/Cmd + Shift + P` | Switch to Plan mode |
121
124
 
125
+ #### Project Picker Modal
126
+
127
+ | Shortcut | Action |
128
+ |----------|--------|
129
+ | `Up/Down` | Navigate projects |
130
+ | `Enter` | Select project |
131
+ | `Esc` / `Backspace` | Close modal |
132
+ | `Type` | Filter projects by name/key |
133
+
122
134
  ## API Endpoints
123
135
 
124
136
  ### Authentication
@@ -151,9 +163,12 @@ Guardian provides automated safety checks before approval decisions:
151
163
  | Method | Endpoint | Description |
152
164
  |--------|----------|-------------|
153
165
  | GET | `/api/projects` | List projects |
154
- | POST | `/api/projects` | Add project |
166
+ | POST | `/api/projects` | Add project (`default: true` optionally sets the default) |
167
+ | PUT | `/api/projects/{key}` | Modify name/path and optionally set default |
168
+ | DELETE | `/api/projects/{key}` | Remove a non-default project |
155
169
  | POST | `/api/projects/select` | Switch project |
156
170
  | POST | `/api/projects/open-thread` | Open thread in project |
171
+ | POST | `/api/projects/open-explorer` | Open project folder in OS file explorer |
157
172
 
158
173
  ### Workspace
159
174
 
@@ -172,7 +187,8 @@ Guardian provides automated safety checks before approval decisions:
172
187
  | GET | `/api/features` | List features |
173
188
  | POST | `/api/features/{key}` | Toggle feature |
174
189
  | GET | `/api/models` | List models |
175
- | GET | `/api/modes` | List modes |
190
+ | GET | `/api/modes` | List collaboration modes |
191
+ | GET | `/api/collab` | List collaboration modes (alias) |
176
192
  | GET | `/api/skills` | List skills |
177
193
  | GET | `/api/apps` | List apps |
178
194
  | GET | `/api/mcp` | List MCP servers |
@@ -181,6 +197,25 @@ Guardian provides automated safety checks before approval decisions:
181
197
  | GET | `/api/config` | Get config |
182
198
  | POST | `/api/approvals/{id}` | Submit approval |
183
199
 
200
+ ### Approvals
201
+
202
+ | Method | Endpoint | Description |
203
+ |--------|----------|-------------|
204
+ | GET | `/api/approvals` | List pending approval requests |
205
+ | POST | `/api/approvals/{request_id}` | Submit approval decision (approve|session|deny) |
206
+
207
+ ### Session
208
+
209
+ | Method | Endpoint | Description |
210
+ |--------|----------|-------------|
211
+ | GET | `/api/session/summary` | Get current session state (active thread, workspace, project, mode, agents) |
212
+
213
+ ### Commands
214
+
215
+ | Method | Endpoint | Description |
216
+ |--------|----------|-------------|
217
+ | POST | `/api/command` | Execute any slash command |
218
+
184
219
  ### Events
185
220
 
186
221
  | Method | Endpoint | Description |
@@ -1,3 +1,3 @@
1
1
  """codex-telegram package."""
2
2
 
3
- __version__ = "0.5.9"
3
+ __version__ = "0.6.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-telegram
3
- Version: 0.5.9
3
+ Version: 0.6.1
4
4
  Summary: Bridge for controlling the Codex App Server from Telegram and Web UI
5
5
  Author: heungtae
6
6
  License: Apache License
@@ -382,6 +382,17 @@ cp conf.toml.example ~/.config/codex-telegram/conf.toml
382
382
  ./run_build_and_main.sh
383
383
  ```
384
384
 
385
+ ## Publishing a Release
386
+
387
+ 1. Update `codex_telegram/__init__.py` (`__version__`), validate the changes, and push the commits.
388
+ 2. In GitHub Releases, create a draft with a matching tag (for example, `v0.6.0` for version `0.6.0`) targeting the intended commit. The commit must contain `.github/workflows/pypi-publish.yml` with the release trigger.
389
+ 3. Write the release notes and publish the Release. Drafts and tag pushes alone do not deploy.
390
+ 4. Wait for **Publish release to PyPI** to succeed: it builds the Web UI, builds/checks the wheel and sdist (including UI assets), publishes to PyPI, then attaches the same distributions and `SHA256SUMS` to the Release.
391
+
392
+ Configure `PYPI_API_TOKEN` as a repository secret or a secret on the `pypi` environment before publishing. Existing environment protection rules still apply; remove required reviewers from that environment if fully automatic publishing is desired. Published prereleases also trigger this workflow, so use a matching Python prerelease version such as `0.6.1rc1` / `v0.6.1rc1`.
393
+
394
+ A published GitHub Release alone does not mean PyPI deployment succeeded; check the workflow result. If attachment fails after PyPI publishing succeeds, rerun only failed jobs to attach the retained files without republishing. For code changes after a successful PyPI upload, use a new version and Release.
395
+
385
396
  ## Documentation
386
397
 
387
398
  | File | Description |
@@ -63,6 +63,8 @@ tests/test_handlers_results.py
63
63
  tests/test_keyboard.py
64
64
  tests/test_main_approval_flow.py
65
65
  tests/test_normalize.py
66
+ tests/test_project_config.py
67
+ tests/test_project_routes.py
66
68
  tests/test_pypi_check.py
67
69
  tests/test_single_instance.py
68
70
  tests/test_telegram_bot_config.py
@@ -92,5 +94,5 @@ web/static/dist/index.html
92
94
  web/static/dist/assets/codex-telegram-icon-black.svg
93
95
  web/static/dist/assets/codex-telegram-icon-ivory.svg
94
96
  web/static/dist/assets/codex-telegram-login-image.png
95
- web/static/dist/assets/index-DIOICBNe.js
96
- web/static/dist/assets/index-DW1AcoLg.css
97
+ web/static/dist/assets/index-0u8Hjl9v.js
98
+ web/static/dist/assets/index-Bh3KHDwf.css
@@ -0,0 +1,42 @@
1
+ import tomllib
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+
6
+ from utils.config import remove_project_profile, save_project_profile, update_project_profile
7
+
8
+
9
+ @pytest.fixture
10
+ def project_config(tmp_path):
11
+ path = tmp_path / "conf.toml"
12
+ path.write_text('project = "alpha"\n\n[projects.alpha]\nname = "Alpha"\npath = "/alpha"\nextra = true\n\n[web]\nenabled = true\n')
13
+ with patch("utils.config._get_config_path", return_value=path):
14
+ yield path
15
+
16
+
17
+ def test_modify_and_change_default_preserves_other_settings(project_config):
18
+ save_project_profile("beta", "Beta", "/beta")
19
+ update_project_profile("beta", 'New "Beta"', r"/new\beta", make_default=True)
20
+ parsed = tomllib.loads(project_config.read_text())
21
+ assert parsed["project"] == "beta"
22
+ assert parsed["projects"]["beta"] == {"name": 'New "Beta"', "path": r"/new\beta"}
23
+ assert parsed["projects"]["alpha"]["extra"] is True
24
+ assert parsed["web"]["enabled"] is True
25
+ with pytest.raises(ValueError, match="default project"):
26
+ remove_project_profile("beta")
27
+ remove_project_profile("alpha")
28
+ assert "alpha" not in tomllib.loads(project_config.read_text())["projects"]
29
+
30
+
31
+ def test_add_default_and_modify_without_unsetting_default(project_config):
32
+ save_project_profile("beta", "Beta", "/beta", make_default=True)
33
+ update_project_profile("beta", "Changed", "/changed")
34
+ assert tomllib.loads(project_config.read_text())["project"] == "beta"
35
+
36
+
37
+ @pytest.mark.parametrize("key,name,path", [("missing", "Name", "/path"), ("alpha", " ", "/path"), ("alpha", "Name", " ")])
38
+ def test_invalid_modify_does_not_write(project_config, key, name, path):
39
+ original = project_config.read_text()
40
+ with pytest.raises(ValueError):
41
+ update_project_profile(key, name, path, make_default=True)
42
+ assert project_config.read_text() == original
@@ -0,0 +1,59 @@
1
+ import asyncio
2
+ import unittest
3
+ from types import SimpleNamespace
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+
8
+ from web.routes import register_project_routes
9
+
10
+
11
+ class ProjectRouteTests(unittest.TestCase):
12
+ def setUp(self):
13
+ app = FastAPI()
14
+ register_project_routes(app)
15
+ self.modify = next(route.endpoint for route in app.routes if "PUT" in getattr(route, "methods", set()))
16
+ self.add = next(route.endpoint for route in app.routes if getattr(route, "path", "") == "/api/projects" and "POST" in route.methods)
17
+
18
+ def test_modify_saves_default_and_reloads(self):
19
+ with (
20
+ patch("web.routes.session_from_request", new=AsyncMock(return_value=SimpleNamespace(user_id=1))),
21
+ patch("web.routes.update_project_profile") as save,
22
+ patch("web.routes.reload") as reload_config,
23
+ patch("web.routes.route_command", new=AsyncMock(return_value={"ok": True})),
24
+ ):
25
+ result = asyncio.run(self.modify("alpha", {"name": "Alpha", "path": "/alpha", "default": True}, object()))
26
+ save.assert_called_once_with("alpha", "Alpha", "/alpha", make_default=True)
27
+ reload_config.assert_called_once()
28
+ self.assertTrue(result["ok"])
29
+
30
+ def test_add_can_set_default(self):
31
+ with (
32
+ patch("web.routes.session_from_request", new=AsyncMock(return_value=SimpleNamespace(user_id=1))),
33
+ patch("web.routes.save_project_profile") as save,
34
+ patch("web.routes.reload"),
35
+ patch("web.routes.route_command", new=AsyncMock(return_value={})),
36
+ ):
37
+ asyncio.run(self.add({"key": "alpha", "name": "Alpha", "path": "/alpha", "default": True}, object()))
38
+ save.assert_called_once_with("alpha", "Alpha", "/alpha", make_default=True)
39
+
40
+ def test_modify_reports_invalid_project(self):
41
+ with (
42
+ patch("web.routes.session_from_request", new=AsyncMock(return_value=SimpleNamespace(user_id=1))),
43
+ patch("web.routes.update_project_profile", side_effect=ValueError("not found")),
44
+ patch("web.routes.reload") as reload_config,
45
+ ):
46
+ with self.assertRaises(HTTPException) as raised:
47
+ asyncio.run(self.modify("missing", {"name": "Name", "path": "/path"}, object()))
48
+ self.assertEqual(400, raised.exception.status_code)
49
+ reload_config.assert_not_called()
50
+
51
+ def test_modify_requires_authentication_before_writing(self):
52
+ with (
53
+ patch("web.routes.session_from_request", new=AsyncMock(side_effect=HTTPException(401))),
54
+ patch("web.routes.update_project_profile") as save,
55
+ ):
56
+ with self.assertRaises(HTTPException) as raised:
57
+ asyncio.run(self.modify("alpha", {"name": "Name", "path": "/path"}, object()))
58
+ self.assertEqual(401, raised.exception.status_code)
59
+ save.assert_not_called()
@@ -1274,6 +1274,50 @@ path_glob_any = ["helm/**"]
1274
1274
  body["items"],
1275
1275
  )
1276
1276
 
1277
+ def test_thread_summaries_paginates_before_project_limit_and_offset(self):
1278
+ app = create_web_app()
1279
+ endpoint = next(route.endpoint for route in app.routes
1280
+ if getattr(route, "path", None) == "/api/threads/summaries")
1281
+ request = SimpleNamespace(cookies={COOKIE_NAME: self.session.token})
1282
+ user_manager.get(self.session.user_id).selected_project_key = "default"
1283
+ for tid in ("t-1", "t-2", "t-3"):
1284
+ user_manager.bind_thread_project(tid, "default")
1285
+ user_manager.bind_thread_project("other", "other")
1286
+ for offset, expected in ((0, ["t-1", "t-2"]), (1, ["t-2", "t-3"])):
1287
+ with self.subTest(offset=offset):
1288
+ state.codex_client.call = AsyncMock(side_effect=[
1289
+ {"data": [{"id": "other"}], "nextCursor": "page-2"},
1290
+ {"data": [{"id": "t-1"}], "nextCursor": "page-3"},
1291
+ {"data": [{"id": "t-1"}, {"id": "t-2"}, {"id": "t-3"}],
1292
+ "nextCursor": "unused"},
1293
+ {"thread": {}}, {"thread": {}},
1294
+ ])
1295
+ body = asyncio.run(endpoint(request, limit=2, offset=offset))
1296
+ self.assertEqual(expected, [item["id"] for item in body["items"]])
1297
+ list_calls = [c for c in state.codex_client.call.await_args_list
1298
+ if c.args[0] == "thread/list"]
1299
+ self.assertEqual(3, len(list_calls))
1300
+ self.assertNotIn("cursor", list_calls[0].args[1])
1301
+ self.assertEqual("page-2", list_calls[1].args[1]["cursor"])
1302
+ self.assertEqual("page-3", list_calls[2].args[1]["cursor"])
1303
+
1304
+ def test_thread_summaries_stops_on_exhaustion_or_repeated_cursor(self):
1305
+ app = create_web_app()
1306
+ endpoint = next(route.endpoint for route in app.routes
1307
+ if getattr(route, "path", None) == "/api/threads/summaries")
1308
+ request = SimpleNamespace(cookies={COOKIE_NAME: self.session.token})
1309
+ user_manager.get(self.session.user_id).selected_project_key = "default"
1310
+ user_manager.bind_thread_project("other", "other")
1311
+ for page in ({"data": [{"id": "other"}]},
1312
+ {"data": None, "nextCursor": "repeat"}):
1313
+ with self.subTest(page=page):
1314
+ state.codex_client.call = AsyncMock(return_value=page)
1315
+ body = asyncio.run(endpoint(request, limit=2, archived=True))
1316
+ self.assertEqual([], body["items"])
1317
+ self.assertEqual(2 if page.get("nextCursor") else 1,
1318
+ state.codex_client.call.await_count)
1319
+ self.assertTrue(state.codex_client.call.await_args.args[1]["archived"])
1320
+
1277
1321
  def test_thread_summaries_use_user_request_excerpt_as_title(self):
1278
1322
  app = create_web_app()
1279
1323
  endpoint = next(
@@ -682,7 +682,9 @@ def save_guardian_settings(
682
682
  return get_guardian_settings()
683
683
 
684
684
 
685
- def save_project_profile(key: str, name: str, path: str) -> None:
685
+ def save_project_profile(
686
+ key: str, name: str, path: str, *, make_default: bool = False
687
+ ) -> None:
686
688
  if not re.fullmatch(r"[A-Za-z0-9_-]+", key):
687
689
  raise ValueError("Invalid project key. Use letters, numbers, '_' or '-'.")
688
690
  if not name.strip():
@@ -711,4 +713,102 @@ def save_project_profile(key: str, name: str, path: str) -> None:
711
713
  f'path = "{_escape_toml_string(path)}"\n'
712
714
  )
713
715
 
716
+ if make_default:
717
+ updated = _with_default_project(updated, key)
718
+ tomllib.loads(updated)
719
+ config_path.write_text(updated, encoding="utf-8")
720
+
721
+
722
+ def _with_default_project(raw: str, key: str) -> str:
723
+ lines = raw.splitlines(keepends=True)
724
+ for index, line in enumerate(lines):
725
+ if line.lstrip().startswith("["):
726
+ break
727
+ if re.match(r"^\s*project\s*=", line):
728
+ lines[index] = f'project = "{_escape_toml_string(key)}"\n'
729
+ return "".join(lines)
730
+ return f'project = "{_escape_toml_string(key)}"\n' + raw
731
+
732
+
733
+ def update_project_profile(
734
+ key: str, name: str, path: str, *, make_default: bool = False
735
+ ) -> None:
736
+ if not name.strip() or not path.strip():
737
+ raise ValueError("Project name and path must not be empty.")
738
+ _ensure_config_exists()
739
+ config_path = _get_config_path()
740
+ raw = config_path.read_text(encoding="utf-8")
741
+ parsed = tomllib.loads(raw)
742
+ if key not in parsed.get("projects", {}):
743
+ raise ValueError(f"Project key '{key}' not found.")
744
+ lines = raw.splitlines(keepends=True)
745
+ start = None
746
+ end = len(lines)
747
+ for index, line in enumerate(lines):
748
+ if not line.lstrip().startswith("["):
749
+ continue
750
+ if start is not None:
751
+ end = index
752
+ break
753
+ try:
754
+ header = tomllib.loads(line.strip())
755
+ except tomllib.TOMLDecodeError:
756
+ continue
757
+ if header.get("projects", {}).get(key) == {}:
758
+ start = index
759
+ if start is None:
760
+ raise ValueError("Project table could not be updated.")
761
+ body = "".join(lines[start + 1:end])
762
+ for field, value in (("name", name), ("path", path)):
763
+ pattern = rf"(?m)^\s*{field}\s*=.*$"
764
+ replacement = f'{field} = "{_escape_toml_string(value)}"'
765
+ if re.search(pattern, body):
766
+ body = re.sub(pattern, lambda match: replacement, body)
767
+ else:
768
+ body = body.rstrip() + "\n" + replacement + "\n"
769
+ updated = "".join(lines[:start + 1]) + body + "".join(lines[end:])
770
+ tomllib.loads(updated)
771
+ if make_default:
772
+ updated = _with_default_project(updated, key)
773
+ tomllib.loads(updated)
774
+ config_path.write_text(updated, encoding="utf-8")
775
+
776
+
777
+ def remove_project_profile(key: str) -> None:
778
+ _ensure_config_exists()
779
+ config_path = _get_config_path()
780
+ raw = config_path.read_text(encoding="utf-8")
781
+ parsed = tomllib.loads(raw) if raw.strip() else {}
782
+
783
+ projects = parsed.get("projects")
784
+ if not isinstance(projects, dict) or key not in projects:
785
+ raise ValueError(f"Project key '{key}' not found.")
786
+
787
+ if parsed.get("project") == key:
788
+ raise ValueError(
789
+ f"Cannot remove the default project '{key}'. "
790
+ "Set another default project first."
791
+ )
792
+
793
+ lines = raw.splitlines(keepends=True)
794
+ result: list[str] = []
795
+ i = 0
796
+ while i < len(lines):
797
+ line = lines[i]
798
+ stripped = line.strip()
799
+ if stripped == f"[projects.{key}]":
800
+ i += 1
801
+ while i < len(lines):
802
+ next_line = lines[i]
803
+ next_stripped = next_line.strip()
804
+ if next_stripped.startswith("[") and not next_stripped.startswith("[projects."):
805
+ break
806
+ if next_stripped.startswith("[projects.") and next_stripped != f"[projects.{key}]":
807
+ break
808
+ i += 1
809
+ continue
810
+ result.append(line)
811
+ i += 1
812
+
813
+ updated = "".join(result).rstrip() + "\n"
714
814
  config_path.write_text(updated, encoding="utf-8")
@@ -16,7 +16,14 @@ from codex_telegram import __version__
16
16
  from codex.collaboration_mode import codex_mode_name
17
17
  from models import state
18
18
  from models.user import user_manager
19
- from utils.config import get, get_web_password, reload, save_project_profile
19
+ from utils.config import (
20
+ get,
21
+ get_web_password,
22
+ reload,
23
+ remove_project_profile,
24
+ save_project_profile,
25
+ update_project_profile,
26
+ )
20
27
  from utils.local_command import resolve_command_cwd
21
28
  from utils.normalize import clamp_int, parse_bool
22
29
  from web.dependencies import (
@@ -322,10 +329,6 @@ def register_thread_routes(app: FastAPI) -> None:
322
329
  params: dict[str, Any] = {"limit": min(100, safe_limit + safe_offset)}
323
330
  if archived:
324
331
  params["archived"] = True
325
- result = await state.codex_client.call("thread/list", params)
326
- rows = result.get("data", [])
327
- if not isinstance(rows, list):
328
- rows = []
329
332
  state_user = user_manager.get(session.user_id)
330
333
  current_profile_key = project_key.strip()
331
334
  if not current_profile_key:
@@ -334,12 +337,30 @@ def register_thread_routes(app: FastAPI) -> None:
334
337
  default_key = get("project")
335
338
  if isinstance(default_key, str) and default_key.strip():
336
339
  current_profile_key = default_key.strip()
337
- if current_profile_key:
338
- rows = [
339
- thread
340
- for thread in rows
341
- if isinstance(thread, dict) and thread_profile_key(thread, current_profile_key) == current_profile_key
342
- ]
340
+ # Apply the limit to matching threads, rather than the global first page.
341
+ rows: list[dict[str, Any]] = []
342
+ seen_ids: set[str] = set()
343
+ seen_cursors: set[str] = set()
344
+ while len(rows) < safe_offset + safe_limit:
345
+ result = await state.codex_client.call("thread/list", dict(params))
346
+ page = result.get("data", [])
347
+ if not isinstance(page, list):
348
+ page = []
349
+ for thread in page:
350
+ if not isinstance(thread, dict):
351
+ continue
352
+ tid = thread.get("id")
353
+ if not isinstance(tid, str) or not tid or tid in seen_ids:
354
+ continue
355
+ seen_ids.add(tid)
356
+ if current_profile_key and thread_profile_key(thread, current_profile_key) != current_profile_key:
357
+ continue
358
+ rows.append(thread)
359
+ cursor = result.get("nextCursor")
360
+ if not isinstance(cursor, str) or not cursor or cursor in seen_cursors:
361
+ break
362
+ seen_cursors.add(cursor)
363
+ params["cursor"] = cursor
343
364
  if safe_offset:
344
365
  rows = rows[safe_offset:]
345
366
  rows = rows[:safe_limit]
@@ -692,7 +713,33 @@ def register_project_routes(app: FastAPI) -> None:
692
713
  name = _required_str(payload, "name")
693
714
  path = _required_str(payload, "path")
694
715
  try:
695
- save_project_profile(key, name, path)
716
+ save_project_profile(key, name, path, make_default=payload.get("default") is True)
717
+ reload()
718
+ except ValueError as exc:
719
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
720
+ return await route_command("/projects", ["--list"], session.user_id)
721
+
722
+ @app.put("/api/projects/{project_key}")
723
+ async def modify_project(
724
+ project_key: str, payload: dict[str, Any], request: Request
725
+ ) -> dict[str, Any]:
726
+ session = await session_from_request(request)
727
+ name = _required_str(payload, "name")
728
+ path = _required_str(payload, "path")
729
+ try:
730
+ update_project_profile(
731
+ project_key, name, path, make_default=payload.get("default") is True
732
+ )
733
+ reload()
734
+ except ValueError as exc:
735
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
736
+ return await route_command("/projects", ["--list"], session.user_id)
737
+
738
+ @app.delete("/api/projects/{project_key}")
739
+ async def remove_project(project_key: str, request: Request) -> dict[str, Any]:
740
+ session = await session_from_request(request)
741
+ try:
742
+ remove_project_profile(project_key)
696
743
  reload()
697
744
  except ValueError as exc:
698
745
  raise HTTPException(status_code=400, detail=str(exc)) from exc