kon-coding-agent 0.3.5__tar.gz → 0.3.6__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 (147) hide show
  1. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/CHANGELOG.md +22 -0
  2. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/PKG-INFO +4 -8
  3. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/README.md +0 -6
  4. kon_coding_agent-0.3.6/docs/code-health-scan.md +144 -0
  5. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/pyproject.toml +9 -3
  6. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/config.py +9 -0
  7. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/defaults/config.toml +5 -0
  8. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/openai_codex_responses.py +21 -5
  9. kon_coding_agent-0.3.6/src/kon/notify.py +30 -0
  10. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools/find.py +1 -3
  11. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools/grep.py +1 -3
  12. kon_coding_agent-0.3.6/src/kon/tools/web_fetch.py +160 -0
  13. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/turn.py +4 -6
  14. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/app.py +15 -1
  15. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/blocks.py +13 -6
  16. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/chat.py +66 -11
  17. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/commands.py +17 -28
  18. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/formatting.py +3 -0
  19. kon_coding_agent-0.3.6/src/kon/ui/latex.py +349 -0
  20. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/styles.py +19 -3
  21. kon_coding_agent-0.3.6/src/kon/ui/welcome.py +85 -0
  22. kon_coding_agent-0.3.6/tests/test_notifications_config.py +64 -0
  23. kon_coding_agent-0.3.6/tests/test_notify.py +46 -0
  24. kon_coding_agent-0.3.6/tests/test_ui_notifications.py +32 -0
  25. kon_coding_agent-0.3.6/tests/ui/test_latex.py +60 -0
  26. kon_coding_agent-0.3.6/tests/ui/test_styles.py +16 -0
  27. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/uv.lock +122 -222
  28. kon_coding_agent-0.3.5/src/kon/tools/web_fetch.py +0 -79
  29. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/.github/workflows/test.yml +0 -0
  30. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/.gitignore +0 -0
  31. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/.kon/skills/kon-release-publish/SKILL.md +0 -0
  32. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/.kon/skills/kon-tmux-test/SKILL.md +0 -0
  33. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/.kon/skills/kon-tmux-test/run-e2e-tests.sh +0 -0
  34. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/.kon/skills/kon-tmux-test/setup-test-project.sh +0 -0
  35. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/.python-version +0 -0
  36. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/AGENTS.md +0 -0
  37. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/LICENSE +0 -0
  38. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/docs/architecture-review.md +0 -0
  39. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/docs/images/kon-screenshot.png +0 -0
  40. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/docs/local-models.md +0 -0
  41. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/scripts/show_themes.py +0 -0
  42. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/__init__.py +0 -0
  43. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/async_utils.py +0 -0
  44. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/builtin_skills/init/SKILL.md +0 -0
  45. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/context/__init__.py +0 -0
  46. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/context/_xml.py +0 -0
  47. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/context/agent_mds.py +0 -0
  48. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/context/git.py +0 -0
  49. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/context/loader.py +0 -0
  50. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/context/skills.py +0 -0
  51. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/core/__init__.py +0 -0
  52. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/core/compaction.py +0 -0
  53. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/core/handoff.py +0 -0
  54. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/core/types.py +0 -0
  55. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/defaults/__init__.py +0 -0
  56. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/events.py +0 -0
  57. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/__init__.py +0 -0
  58. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/base.py +0 -0
  59. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/models.py +0 -0
  60. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/oauth/__init__.py +0 -0
  61. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/oauth/copilot.py +0 -0
  62. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/oauth/openai.py +0 -0
  63. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/__init__.py +0 -0
  64. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/anthropic.py +0 -0
  65. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/azure_ai_foundry.py +0 -0
  66. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/copilot.py +0 -0
  67. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/copilot_anthropic.py +0 -0
  68. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/github_copilot_headers.py +0 -0
  69. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/mock.py +0 -0
  70. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/openai_compat.py +0 -0
  71. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/openai_completions.py +0 -0
  72. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/openai_responses.py +0 -0
  73. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/llm/providers/sanitize.py +0 -0
  74. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/loop.py +0 -0
  75. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/permissions.py +0 -0
  76. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/py.typed +0 -0
  77. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/session.py +0 -0
  78. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/themes.py +0 -0
  79. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools/__init__.py +0 -0
  80. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools/_read_image.py +0 -0
  81. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools/_tool_utils.py +0 -0
  82. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools/base.py +0 -0
  83. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools/bash.py +0 -0
  84. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools/edit.py +0 -0
  85. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools/read.py +0 -0
  86. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools/web_search.py +0 -0
  87. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools/write.py +0 -0
  88. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/tools_manager.py +0 -0
  89. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/__init__.py +0 -0
  90. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/app_protocol.py +0 -0
  91. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/autocomplete.py +0 -0
  92. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/clipboard.py +0 -0
  93. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/export.py +0 -0
  94. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/floating_list.py +0 -0
  95. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/input.py +0 -0
  96. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/path_complete.py +0 -0
  97. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/prompt_history.py +0 -0
  98. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/selection_mode.py +0 -0
  99. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/session_ui.py +0 -0
  100. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/ui/widgets.py +0 -0
  101. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/src/kon/update_check.py +0 -0
  102. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/conftest.py +0 -0
  103. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/context/test_agents.py +0 -0
  104. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/context/test_skills.py +0 -0
  105. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/llm/__init__.py +0 -0
  106. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/llm/test_anthropic_provider.py +0 -0
  107. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/llm/test_azure_ai_foundry_provider.py +0 -0
  108. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/llm/test_mock_provider.py +0 -0
  109. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/llm/test_openai_codex_provider_errors.py +0 -0
  110. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/llm/test_openai_oauth.py +0 -0
  111. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_agentic_loop.py +0 -0
  112. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_cli_auth_flags.py +0 -0
  113. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_cli_provider_resolution.py +0 -0
  114. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_compaction.py +0 -0
  115. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_config_binaries.py +0 -0
  116. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_config_error_fallback.py +0 -0
  117. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_config_injection.py +0 -0
  118. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_config_migration.py +0 -0
  119. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_handoff.py +0 -0
  120. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_handoff_link_interrupt.py +0 -0
  121. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_launch_warnings.py +0 -0
  122. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_local_auth_config.py +0 -0
  123. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_model_provider_resolution.py +0 -0
  124. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_openai_compat.py +0 -0
  125. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_permissions.py +0 -0
  126. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_session_persistence.py +0 -0
  127. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_session_queries.py +0 -0
  128. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_session_resume.py +0 -0
  129. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_system_prompt.py +0 -0
  130. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_system_prompt_git_context.py +0 -0
  131. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_tools_manager.py +0 -0
  132. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_update_check.py +0 -0
  133. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/test_update_notice_behavior.py +0 -0
  134. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/tools/test_diff.py +0 -0
  135. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/tools/test_edit.py +0 -0
  136. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/tools/test_edit_display.py +0 -0
  137. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/tools/test_read.py +0 -0
  138. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/tools/test_read_image.py +0 -0
  139. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/tools/test_read_image_integration.py +0 -0
  140. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/tools/test_subprocess_cancellation.py +0 -0
  141. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/tools/test_write.py +0 -0
  142. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/ui/test_autocomplete.py +0 -0
  143. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/ui/test_floating_list.py +0 -0
  144. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/ui/test_input_handoff.py +0 -0
  145. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/ui/test_input_paste.py +0 -0
  146. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/ui/test_prompt_history.py +0 -0
  147. {kon_coding_agent-0.3.5 → kon_coding_agent-0.3.6}/tests/ui/test_status_line.py +0 -0
@@ -6,6 +6,28 @@ All notable changes to this project will be documented in this file.
6
6
 
7
7
  - No changes yet.
8
8
 
9
+ ## 0.3.6 - 2026-04-23
10
+
11
+ ### Added
12
+
13
+ - Configurable terminal bell notifications on response completion.
14
+ - Render LaTeX math as Unicode in markdown output - @toojays.
15
+ - Style approval popup with button-like controls and panel background.
16
+ - Permission popup title card and improved UI formatting.
17
+
18
+ ### Changed
19
+
20
+ - Improved UI for approval popup.
21
+ - Reduce LaTeX preprocessing overhead.
22
+ - Improved Codex SSE error extraction and retry on transient errors.
23
+
24
+ ### Fixed
25
+
26
+ - Rewrite web_fetch to bypass bot-detection, preserve indentation, and harden against SSRF - @Meltedd (#28, #29).
27
+ - Remove unused turn streaming state.
28
+ - Remove unused tool imports.
29
+ - Remove empty blocks.
30
+
9
31
  ## 0.3.5 - 2026-04-18
10
32
 
11
33
  ### Added
@@ -1,19 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kon-coding-agent
3
- Version: 0.3.5
3
+ Version: 0.3.6
4
4
  Summary: Minimal coding agent
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
7
7
  Requires-Dist: aiofiles>=25.1.0
8
8
  Requires-Dist: aiohttp>=3.13.3
9
9
  Requires-Dist: anthropic>=0.79.0
10
+ Requires-Dist: curl-cffi>=0.15.0
10
11
  Requires-Dist: ddgs>=9.0.0
12
+ Requires-Dist: html-to-markdown>=3.1.0
11
13
  Requires-Dist: openai>=2.21.0
12
14
  Requires-Dist: pillow>=12.1.1
13
15
  Requires-Dist: pydantic>=2.12.5
16
+ Requires-Dist: readability-lxml>=0.8.4
14
17
  Requires-Dist: rich>=14.3.2
15
18
  Requires-Dist: textual>=8.0.0
16
- Requires-Dist: trafilatura>=2.0.0
17
19
  Description-Content-Type: text/markdown
18
20
 
19
21
  <h1 align="center">Kon</h1>
@@ -475,12 +477,6 @@ If `fd` or `rg` are missing, Kon can download them automatically.
475
477
 
476
478
  ---
477
479
 
478
- ## Documentation
479
-
480
- - [Local models](docs/local-models.md) - Running Kon with local OpenAI-compatible models, plus tested setups and example commands.
481
-
482
- ---
483
-
484
480
  ## Acknowledgements
485
481
 
486
482
  - Kon takes significant inspiration from [pi coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent), especially around philosophy and UI direction.
@@ -457,12 +457,6 @@ If `fd` or `rg` are missing, Kon can download them automatically.
457
457
 
458
458
  ---
459
459
 
460
- ## Documentation
461
-
462
- - [Local models](docs/local-models.md) - Running Kon with local OpenAI-compatible models, plus tested setups and example commands.
463
-
464
- ---
465
-
466
460
  ## Acknowledgements
467
461
 
468
462
  - Kon takes significant inspiration from [pi coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent), especially around philosophy and UI direction.
@@ -0,0 +1,144 @@
1
+ # Code Health Scan: Additional Notable Issues
2
+
3
+ This note captures only the additional issues found in a fresh repo scan that are **not** the same as the already-known architectural concerns in `docs/architecture-review.md`.
4
+
5
+ The baseline conclusion from the earlier review still stands:
6
+
7
+ - the core runtime is strong
8
+ - the TUI still owns too much runtime/session orchestration
9
+ - the large controller/runtime-manager refactor is deferred for now
10
+
11
+ This document is intentionally narrower. It records only the standout issue and the smaller cleanup opportunities found in the current repo state after the recent cleanup pass.
12
+
13
+ ## Overall verdict
14
+
15
+ Aside from the already-documented UI/runtime orchestration boundary issue, the repo looks to be in decent shape overall.
16
+
17
+ Notable signals:
18
+
19
+ - test suite passed cleanly
20
+ - pyright passed cleanly
21
+ - only a few lint issues remained
22
+
23
+ So this is **not** a case where the repo appears to have multiple additional architectural problems lurking elsewhere. The main new thing worth tracking is a state-consistency problem around provider/model transitions, plus a few smaller cleanup items.
24
+
25
+ ## Major issue
26
+
27
+ ### Provider/model transition paths are not transactional
28
+
29
+ The main additional issue is that some provider/model/session transition flows update app state before it is certain that the replacement provider/runtime state can be constructed successfully.
30
+
31
+ Relevant files:
32
+
33
+ - `src/kon/ui/commands.py`
34
+ - `src/kon/ui/session_ui.py`
35
+
36
+ ### Where it shows up
37
+
38
+ #### `src/kon/ui/commands.py::_select_model`
39
+
40
+ This path updates:
41
+
42
+ - `self._model`
43
+ - `self._model_provider`
44
+
45
+ before provider recreation is guaranteed to succeed.
46
+
47
+ If `_create_provider(...)` fails, the UI reports the error, but some selected-model state may already have been changed while the active provider/agent runtime is still using the old provider instance.
48
+
49
+ #### `src/kon/ui/session_ui.py::_load_session`
50
+
51
+ This path loads session metadata and updates model/provider-related state while also trying to reconcile the active provider.
52
+
53
+ If provider recreation fails during session load, the code reports the error, but can still leave partially updated state behind:
54
+
55
+ - loaded session state may now be active
56
+ - selected model/provider values may reflect the resumed session
57
+ - the provider instance may still be the old one or only partially reconfigured
58
+
59
+ ### Why this matters
60
+
61
+ This is not a large-architecture problem, but it is a real correctness risk.
62
+
63
+ It can produce subtle mismatches between:
64
+
65
+ - selected model/provider UI state
66
+ - active provider config
67
+ - agent runtime state
68
+ - resumed session metadata
69
+
70
+ Those failures are harder to reason about than a simple hard error because the app can continue running in a partially updated state.
71
+
72
+ ### Suggested direction
73
+
74
+ Without doing the large architecture refactor, the safer short-term fix would be to make these transitions more atomic:
75
+
76
+ 1. compute the target model/provider/session state first
77
+ 2. build or validate the replacement provider first
78
+ 3. only commit the new state to `self._model`, `self._model_provider`, `self._provider`, `self._session`, and `self._agent` after success
79
+ 4. otherwise leave the old runtime state untouched
80
+
81
+ Even a small helper that stages the new state before assignment would reduce the sync-risk significantly.
82
+
83
+ ## Small cleanup opportunities
84
+
85
+ ### 1. `CompactionEntry.first_kept_entry_id` is currently misleading
86
+
87
+ Relevant file:
88
+
89
+ - `src/kon/session.py`
90
+
91
+ `CompactionEntry` stores `first_kept_entry_id`, and comments imply that the compacted view depends on it.
92
+
93
+ However, `Session.messages` currently reconstructs the compacted view by:
94
+
95
+ - finding the last compaction entry
96
+ - inserting the synthetic summary pair
97
+ - including message entries after the compaction entry itself
98
+
99
+ It does **not** currently use `first_kept_entry_id` to decide what to retain.
100
+
101
+ This is not a functional bug in current usage because existing callers/tests append compaction entries in a way that still makes the behavior correct. But the data model and implementation intent are slightly out of sync, which makes the field more confusing than helpful right now.
102
+
103
+ Small fix options:
104
+
105
+ - actually use `first_kept_entry_id` in `Session.messages`, or
106
+ - simplify the field/comment contract if the compaction-entry position is the real source of truth
107
+
108
+ ### 2. System-prompt fallback logic is still duplicated across UI mixins
109
+
110
+ Relevant files:
111
+
112
+ - `src/kon/ui/commands.py`
113
+ - `src/kon/ui/session_ui.py`
114
+
115
+ Both files define the same `_resolve_system_prompt(...)` helper:
116
+
117
+ - use persisted `session.system_prompt` when available
118
+ - otherwise rebuild with `build_system_prompt(...)`
119
+
120
+ This is a small issue, not a major design problem, but it means the recent centralization is still only partial. If the fallback behavior changes again, both copies will need to stay in sync.
121
+
122
+ Small fix option:
123
+
124
+ - move the helper to one shared non-duplicated location used by both mixins/app paths
125
+
126
+ ### 3. Lint cleanup remains in a few tool modules
127
+
128
+ Relevant files:
129
+
130
+ - `src/kon/tools/find.py`
131
+ - `src/kon/tools/grep.py`
132
+ - `src/kon/tools/web_fetch.py`
133
+
134
+ Current lint output shows unused imports of `truncate_text` in these files.
135
+
136
+ This is only a hygiene issue, but it is a useful signal that a full lint pass likely did not happen after some recent edits.
137
+
138
+ ## Final assessment
139
+
140
+ The repo looks fine overall.
141
+
142
+ Aside from the already-known concerns in `docs/architecture-review.md`, the only additional issue that really stands out is the non-transactional provider/model/session transition behavior.
143
+
144
+ The other findings are small cleanup opportunities rather than signs of deeper architectural trouble.
@@ -14,7 +14,7 @@ default = true
14
14
 
15
15
  [project]
16
16
  name = "kon-coding-agent"
17
- version = "0.3.5"
17
+ version = "0.3.6"
18
18
  description = "Minimal coding agent"
19
19
  readme = "README.md"
20
20
  requires-python = ">=3.12"
@@ -22,13 +22,15 @@ dependencies = [
22
22
  "aiofiles>=25.1.0",
23
23
  "aiohttp>=3.13.3",
24
24
  "anthropic>=0.79.0",
25
+ "curl-cffi>=0.15.0",
26
+ "ddgs>=9.0.0",
27
+ "html-to-markdown>=3.1.0",
25
28
  "openai>=2.21.0",
26
29
  "pillow>=12.1.1",
27
30
  "pydantic>=2.12.5",
31
+ "readability-lxml>=0.8.4",
28
32
  "rich>=14.3.2",
29
33
  "textual>=8.0.0",
30
- "ddgs>=9.0.0",
31
- "trafilatura>=2.0.0",
32
34
  ]
33
35
 
34
36
  [dependency-groups]
@@ -60,6 +62,10 @@ select = [
60
62
  "RUF", # ruff-specific rules
61
63
  ]
62
64
 
65
+ [tool.ruff.lint.per-file-ignores]
66
+ "src/kon/ui/latex.py" = ["RUF001"]
67
+ "tests/ui/test_latex.py" = ["RUF001"]
68
+
63
69
  [tool.ruff.lint.isort]
64
70
  split-on-trailing-comma = false
65
71
 
@@ -98,6 +98,10 @@ class ToolsConfig(BaseModel):
98
98
  extra: list[str] = []
99
99
 
100
100
 
101
+ class NotificationsConfig(BaseModel):
102
+ enabled: bool = False
103
+
104
+
101
105
  class ConfigSchema(BaseModel):
102
106
  meta: MetaConfig
103
107
  llm: LLMConfig
@@ -106,6 +110,7 @@ class ConfigSchema(BaseModel):
106
110
  agent: AgentConfig
107
111
  tools: ToolsConfig = ToolsConfig()
108
112
  permissions: PermissionsConfig
113
+ notifications: NotificationsConfig = NotificationsConfig()
109
114
 
110
115
 
111
116
  # =================================================================================================
@@ -194,6 +199,10 @@ class Config:
194
199
  def tools(self) -> ToolsConfig:
195
200
  return self._parsed.tools
196
201
 
202
+ @property
203
+ def notifications(self) -> NotificationsConfig:
204
+ return self._parsed.notifications
205
+
197
206
  @property
198
207
  def binaries(self) -> _BinariesConfig:
199
208
  return _BinariesConfig(AVAILABLE_BINARIES)
@@ -73,3 +73,8 @@ collapse_thinking = true
73
73
  # "prompt" asks before edits/writes and other mutating actions.
74
74
  # "auto" allows tool calls without approval prompts.
75
75
  mode = "prompt" # "prompt" or "auto"
76
+
77
+ [notifications]
78
+ # Ring the terminal bell when kon finishes a task or waits for tool approval.
79
+ # Disabled by default.
80
+ enabled = false
@@ -279,11 +279,26 @@ class OpenAICodexResponsesProvider(BaseProvider):
279
279
  yield StreamDone(stop_reason=stop_reason)
280
280
  return
281
281
 
282
- elif event_type in {"response.failed", "error"}:
282
+ elif event_type == "error":
283
+ code = event.get("code")
283
284
  message = event.get("message")
284
- if not isinstance(message, str):
285
- message = "Codex response failed"
286
- yield StreamError(error=message)
285
+ if isinstance(message, str) and message:
286
+ yield StreamError(error=f"Codex error: {message}")
287
+ elif isinstance(code, str) and code:
288
+ yield StreamError(error=f"Codex error: {code}")
289
+ else:
290
+ yield StreamError(error=f"Codex error: {json.dumps(event)}")
291
+ return
292
+
293
+ elif event_type == "response.failed":
294
+ response_obj = event.get("response")
295
+ msg = None
296
+ if isinstance(response_obj, dict):
297
+ err = response_obj.get("error")
298
+ if isinstance(err, dict):
299
+ msg = err.get("message")
300
+ err_msg = msg if isinstance(msg, str) and msg else "Codex response failed"
301
+ yield StreamError(error=err_msg)
287
302
  return
288
303
  finally:
289
304
  await session.close()
@@ -321,7 +336,8 @@ class OpenAICodexResponsesProvider(BaseProvider):
321
336
  return StopReason.STOP
322
337
 
323
338
  def should_retry_for_error(self, error: Exception) -> bool:
324
- return False
339
+ msg = str(error).lower()
340
+ return any(kw in msg for kw in ("429", "rate_limit", "server_error", "502", "503", "504"))
325
341
 
326
342
 
327
343
  def is_openai_logged_in() -> bool:
@@ -0,0 +1,30 @@
1
+ import os
2
+ import time
3
+
4
+ _BELL_DEBOUNCE_S = 0.5
5
+ _last_bell_time: float = 0.0
6
+
7
+
8
+ def _raw_write(data: bytes) -> None:
9
+ try:
10
+ fd = os.open("/dev/tty", os.O_WRONLY | os.O_NOCTTY)
11
+ try:
12
+ os.write(fd, data)
13
+ finally:
14
+ os.close(fd)
15
+ except OSError:
16
+ os.write(2, data)
17
+
18
+
19
+ def _bell() -> None:
20
+ global _last_bell_time
21
+ now = time.monotonic()
22
+ if now - _last_bell_time < _BELL_DEBOUNCE_S:
23
+ return
24
+ _last_bell_time = now
25
+ _raw_write(b"\a")
26
+
27
+
28
+ def notify(title: str, message: str) -> None:
29
+ del title, message
30
+ _bell()
@@ -10,7 +10,6 @@ from ._tool_utils import (
10
10
  communicate_or_cancel,
11
11
  shorten_path,
12
12
  truncate_lines_by_bytes,
13
- truncate_text,
14
13
  )
15
14
  from .base import BaseTool
16
15
 
@@ -44,8 +43,7 @@ class FindTool(BaseTool):
44
43
  parts = [f'"{pattern}"']
45
44
  if params.path:
46
45
  parts.append(f"in {shorten_path(params.path)}")
47
- message = " ".join(parts)
48
- return truncate_text(message)
46
+ return " ".join(parts)
49
47
 
50
48
  async def execute(
51
49
  self, params: FindParams, cancel_event: asyncio.Event | None = None
@@ -11,7 +11,6 @@ from ._tool_utils import (
11
11
  communicate_or_cancel,
12
12
  shorten_path,
13
13
  truncate_lines_by_bytes,
14
- truncate_text,
15
14
  )
16
15
  from .base import BaseTool
17
16
 
@@ -49,8 +48,7 @@ class GrepTool(BaseTool):
49
48
  parts.append(f"in {shorten_path(params.path)}")
50
49
  if params.include:
51
50
  parts.append(f"({params.include})")
52
- message = " ".join(parts)
53
- return truncate_text(message)
51
+ return " ".join(parts)
54
52
 
55
53
  async def execute(
56
54
  self, params: GrepParams, cancel_event: asyncio.Event | None = None
@@ -0,0 +1,160 @@
1
+ import asyncio
2
+ import ipaddress
3
+ from urllib.parse import urlparse
4
+
5
+ from curl_cffi import AsyncSession, CurlOpt
6
+ from html_to_markdown import ConversionOptions, convert
7
+ from lxml import html as lxml_html
8
+ from pydantic import BaseModel, Field
9
+ from readability import Document
10
+
11
+ from ..core.types import ToolResult
12
+ from ._tool_utils import ToolCancelledError, await_task_or_cancel
13
+ from .base import BaseTool
14
+
15
+ MAX_CHARS = 80_000
16
+ MAX_CHARS_PER_LINE = 2000
17
+ MAX_RESPONSE_BYTES = 20_000_000
18
+ REQUEST_TIMEOUT = 15
19
+
20
+ # Only checked on failure paths, so false positives can't suppress real content.
21
+ _CHALLENGE_SIGNATURES = (
22
+ "please wait for verification", # Reddit 200 JS challenge
23
+ "prove your humanity", # Reddit 200 reCAPTCHA gate
24
+ "whoa there, pardner", # Reddit 403 ratelimit page
25
+ "just a moment...", # Cloudflare
26
+ "checking your browser", # Cloudflare (legacy)
27
+ "attention required", # Cloudflare block page
28
+ )
29
+
30
+ # Inline SVG data URIs would otherwise become base64 noise.
31
+ _CONVERT_OPTIONS = ConversionOptions(skip_images=True)
32
+
33
+ # concat(';', ...) anchors to a property boundary to block url() false positives.
34
+ _HIDDEN_XPATH = (
35
+ "//script | //style | //noscript | //template"
36
+ " | //*[@hidden or @aria-hidden='true']"
37
+ " | //*[contains(concat(';', translate(@style, ' \t\n', '')), ';display:none')]"
38
+ " | //*[contains(concat(';', translate(@style, ' \t\n', '')), ';visibility:hidden')]"
39
+ )
40
+
41
+
42
+ def _looks_like_challenge(html: str) -> bool:
43
+ head = html[:4096].lower()
44
+ return any(sig in head for sig in _CHALLENGE_SIGNATURES)
45
+
46
+
47
+ def _is_link_local(ip: str) -> bool:
48
+ try:
49
+ return ipaddress.ip_address(ip).is_link_local
50
+ except ValueError:
51
+ return True # fail closed
52
+
53
+
54
+ def _extract_markdown(html: str) -> str | None:
55
+ if not html:
56
+ return None
57
+ try:
58
+ tree = lxml_html.fromstring(html)
59
+ for node in tree.xpath(_HIDDEN_XPATH):
60
+ if (parent := node.getparent()) is not None:
61
+ parent.remove(node)
62
+ doc_input = tree
63
+ except Exception:
64
+ doc_input = html # fall back to readability's own lenient parser
65
+ summary_html = Document(doc_input).summary()
66
+ if not summary_html or len(summary_html) < 50:
67
+ return None
68
+ return convert(summary_html, _CONVERT_OPTIONS).content or None
69
+
70
+
71
+ class WebFetchParams(BaseModel):
72
+ url: str = Field(description="URL of the web page to fetch and extract content from")
73
+
74
+
75
+ class WebFetchTool(BaseTool):
76
+ name = "web_fetch"
77
+ tool_icon = "%"
78
+ mutating = False
79
+ params = WebFetchParams
80
+ description = (
81
+ "Fetch a web page and extract its main content as clean markdown. "
82
+ "Strips navigation, ads, and boilerplate. "
83
+ "Use web_search first to find relevant URLs (if not provided by the user)."
84
+ )
85
+
86
+ def format_call(self, params: WebFetchParams) -> str:
87
+ return params.url
88
+
89
+ async def execute(
90
+ self, params: WebFetchParams, cancel_event: asyncio.Event | None = None
91
+ ) -> ToolResult:
92
+ scheme = urlparse(params.url).scheme
93
+ if scheme not in ("http", "https"):
94
+ msg = f"Refused: unsupported scheme {scheme!r}"
95
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
96
+
97
+ try:
98
+ async with AsyncSession(
99
+ impersonate="chrome131",
100
+ allow_redirects="safe",
101
+ curl_options={CurlOpt.MAXFILESIZE_LARGE: MAX_RESPONSE_BYTES},
102
+ ) as session:
103
+ fetch_task = asyncio.create_task(session.get(params.url, timeout=REQUEST_TIMEOUT))
104
+ response = await await_task_or_cancel(fetch_task, cancel_event)
105
+ except ToolCancelledError:
106
+ return ToolResult(success=False, result="Fetch aborted")
107
+ except Exception as e:
108
+ msg = f"Fetch failed: {e}"
109
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
110
+
111
+ if _is_link_local(response.primary_ip):
112
+ msg = f"Refused: link-local address ({response.primary_ip or 'unknown'})"
113
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
114
+
115
+ # Catches decompression bombs (MAXFILESIZE_LARGE only bounds wire bytes).
116
+ if len(response.content) > MAX_RESPONSE_BYTES:
117
+ msg = f"Response too large (>{MAX_RESPONSE_BYTES:,} bytes decoded)"
118
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
119
+
120
+ if not (200 <= response.status_code < 300):
121
+ status = f"HTTP {response.status_code}"
122
+ msg = (
123
+ f"Site appears to block automated fetchers ({status})"
124
+ if _looks_like_challenge(response.text)
125
+ else status
126
+ )
127
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
128
+
129
+ html = response.text
130
+
131
+ try:
132
+ extract_task = asyncio.create_task(asyncio.to_thread(_extract_markdown, html))
133
+ content = await await_task_or_cancel(extract_task, cancel_event)
134
+ except ToolCancelledError:
135
+ return ToolResult(success=False, result="Extraction aborted")
136
+ except Exception as e:
137
+ msg = f"Extraction failed: {e}"
138
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
139
+
140
+ if not content:
141
+ msg = (
142
+ "Site appears to block automated fetchers"
143
+ if _looks_like_challenge(html)
144
+ else "Couldn't extract content"
145
+ )
146
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
147
+
148
+ content = "\n".join(line[:MAX_CHARS_PER_LINE] for line in content.split("\n"))
149
+
150
+ char_count = len(content)
151
+ truncated = char_count > MAX_CHARS
152
+ if truncated:
153
+ cut = content.rfind("\n", 0, MAX_CHARS)
154
+ content = content[: cut if cut > 0 else MAX_CHARS] + "\n\n[content truncated]"
155
+
156
+ ui_summary = f"[dim]({char_count:,} chars)[/dim]"
157
+ if truncated:
158
+ ui_summary += " [yellow](truncated)[/yellow]"
159
+
160
+ return ToolResult(success=True, result=content, ui_summary=ui_summary)
@@ -312,7 +312,6 @@ async def run_single_turn(
312
312
  current_state: StreamState | None = None
313
313
  stop_reason: StopReason = StopReason.STOP
314
314
  interrupted = False
315
- has_meaningful_output = False
316
315
 
317
316
  def _finalize_current_state(include_empty: bool = True) -> list[StreamEvent]:
318
317
  nonlocal current_state, current_tool_call, think_buffer, think_signature, text_buffer
@@ -437,14 +436,16 @@ async def run_single_turn(
437
436
 
438
437
  current_state = StreamState.THINK
439
438
  think_buffer.append(t)
440
- has_meaningful_output = True
441
439
  if sig:
442
440
  think_signature = sig
443
441
 
444
442
  yield ThinkingDeltaEvent(delta=t)
445
443
 
446
444
  case TextPart(text=t):
447
- if not has_meaningful_output and not t.strip():
445
+ # Skip whitespace-only text that would start a new (empty)
446
+ # content block — prevents phantom gaps between thinking
447
+ # and tool-call blocks.
448
+ if not t.strip() and current_state != StreamState.TEXT:
448
449
  continue
449
450
 
450
451
  if current_state and current_state != StreamState.TEXT:
@@ -456,14 +457,11 @@ async def run_single_turn(
456
457
 
457
458
  current_state = StreamState.TEXT
458
459
  text_buffer.append(t)
459
- if t.strip():
460
- has_meaningful_output = True
461
460
 
462
461
  yield TextDeltaEvent(delta=t)
463
462
 
464
463
  case ToolCallStart(id=id, name=name, arguments=initial_arguments):
465
464
  tool_call_count += 1
466
- has_meaningful_output = True
467
465
  if current_state and current_state != StreamState.TOOL_CALL:
468
466
  for finalize_event in _finalize_current_state():
469
467
  yield finalize_event
@@ -61,6 +61,7 @@ from ..llm import (
61
61
  )
62
62
  from ..llm.base import AuthMode
63
63
  from ..loop import Agent
64
+ from ..notify import notify
64
65
  from ..permissions import ApprovalResponse
65
66
  from ..session import Session
66
67
  from ..tools import DEFAULT_TOOLS, EXTRA_TOOLS, get_tool, get_tools
@@ -94,13 +95,14 @@ _CHANGELOG_URL = "https://github.com/0xku/kon/blob/main/CHANGELOG.md"
94
95
  try:
95
96
  VERSION = version(_PYPI_PACKAGE_NAME)
96
97
  except PackageNotFoundError:
97
- VERSION = "0.3.5"
98
+ VERSION = "0.3.6"
98
99
 
99
100
  _COPILOT_API_TYPES: frozenset[ApiType] = frozenset(
100
101
  {ApiType.GITHUB_COPILOT, ApiType.GITHUB_COPILOT_RESPONSES, ApiType.ANTHROPIC_COPILOT}
101
102
  )
102
103
 
103
104
  _OPENAI_OAUTH_API_TYPES: frozenset[ApiType] = frozenset({ApiType.OPENAI_CODEX_RESPONSES})
105
+ _NOTIFY_EVENTS = (AgentEndEvent, ToolApprovalEvent)
104
106
 
105
107
  _API_TYPE_BY_PROVIDER: dict[type[BaseProvider], ApiType] = {
106
108
  v: k for k, v in API_TYPE_TO_PROVIDER_CLASS.items()
@@ -846,6 +848,15 @@ class Kon(CommandsMixin, SessionUIMixin, App[None]):
846
848
  normal_items = [(display, False) for display, _ in self._pending_queue]
847
849
  queue_display.update_items(steer_items + normal_items)
848
850
 
851
+ def _should_notify_for_event(self, event: object) -> bool:
852
+ if not config.notifications.enabled:
853
+ return False
854
+ if not isinstance(event, _NOTIFY_EVENTS):
855
+ return False
856
+ return not (
857
+ isinstance(event, AgentEndEvent) and event.stop_reason == StopReason.INTERRUPTED
858
+ )
859
+
849
860
  async def _run_agent(self, prompt: str) -> None:
850
861
  chat = self.query_one("#chat-log", ChatLog)
851
862
  status = self.query_one("#status-line", StatusLine)
@@ -890,6 +901,9 @@ class Kon(CommandsMixin, SessionUIMixin, App[None]):
890
901
  async for event in self._agent.run(
891
902
  current_prompt, cancel_event=self._cancel_event, steer_event=self._steer_event
892
903
  ):
904
+ if self._should_notify_for_event(event):
905
+ notify("kon", "Task complete")
906
+
893
907
  match event:
894
908
  case AgentStartEvent():
895
909
  pass