webbee 0.5.7__tar.gz → 0.5.9__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 (145) hide show
  1. {webbee-0.5.7 → webbee-0.5.9}/CHANGELOG.md +15 -0
  2. {webbee-0.5.7 → webbee-0.5.9}/PKG-INFO +6 -4
  3. {webbee-0.5.7 → webbee-0.5.9}/README.md +5 -3
  4. {webbee-0.5.7 → webbee-0.5.9}/pyproject.toml +1 -1
  5. webbee-0.5.9/src/webbee/__init__.py +1 -0
  6. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/checkpoints.py +5 -3
  7. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/consent.py +1 -1
  8. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/service.py +18 -2
  9. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/modals.py +63 -17
  10. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tools.py +23 -1
  11. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tui.py +15 -0
  12. {webbee-0.5.7 → webbee-0.5.9}/tests/test_checkpoints.py +14 -0
  13. {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_service.py +39 -0
  14. {webbee-0.5.7 → webbee-0.5.9}/tests/test_modals_e2e.py +114 -1
  15. {webbee-0.5.7 → webbee-0.5.9}/tests/test_tools.py +37 -0
  16. webbee-0.5.7/src/webbee/__init__.py +0 -1
  17. {webbee-0.5.7 → webbee-0.5.9}/.github/workflows/publish.yml +0 -0
  18. {webbee-0.5.7 → webbee-0.5.9}/.gitignore +0 -0
  19. {webbee-0.5.7 → webbee-0.5.9}/LICENSE +0 -0
  20. {webbee-0.5.7 → webbee-0.5.9}/install.sh +0 -0
  21. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/__main__.py +0 -0
  22. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/_launch.py +0 -0
  23. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/account.py +0 -0
  24. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/active_sessions.py +0 -0
  25. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/banner_art.py +0 -0
  26. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/boot.py +0 -0
  27. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/browser.py +0 -0
  28. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/cli.py +0 -0
  29. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/clipboard.py +0 -0
  30. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/clipboard_read.py +0 -0
  31. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/clipboard_session.py +0 -0
  32. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/coding_context.py +0 -0
  33. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/commands.py +0 -0
  34. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/config.py +0 -0
  35. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/details.py +0 -0
  36. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/events.py +0 -0
  37. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/frames.py +0 -0
  38. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/home.py +0 -0
  39. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/home_view.py +0 -0
  40. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/http.py +0 -0
  41. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/instance_lock.py +0 -0
  42. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/__init__.py +0 -0
  43. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/graph.py +0 -0
  44. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/outline.py +0 -0
  45. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/query.py +0 -0
  46. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/store.py +0 -0
  47. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/vectors.py +0 -0
  48. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/watch.py +0 -0
  49. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/media.py +0 -0
  50. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/mode_store.py +0 -0
  51. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/native_files.py +0 -0
  52. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/native_read.py +0 -0
  53. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/newtab_mode.py +0 -0
  54. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/output_pane.py +0 -0
  55. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/pricing_constants.py +0 -0
  56. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/queue_panel.py +0 -0
  57. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/reflow.py +0 -0
  58. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/remote.py +0 -0
  59. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/render.py +0 -0
  60. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/repl.py +0 -0
  61. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/repo.py +0 -0
  62. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/selection.py +0 -0
  63. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/session.py +0 -0
  64. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/sessions.py +0 -0
  65. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/sizing.py +0 -0
  66. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/slots.py +0 -0
  67. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/steer.py +0 -0
  68. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/stream.py +0 -0
  69. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tab_store.py +0 -0
  70. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tabs.py +0 -0
  71. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/thread.py +0 -0
  72. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tier_store.py +0 -0
  73. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/todo_panel.py +0 -0
  74. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tokens.py +0 -0
  75. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/update.py +0 -0
  76. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/urlopen.py +0 -0
  77. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/wallet.py +0 -0
  78. {webbee-0.5.7 → webbee-0.5.9}/src/webbee/worktrees.py +0 -0
  79. {webbee-0.5.7 → webbee-0.5.9}/tests/__init__.py +0 -0
  80. {webbee-0.5.7 → webbee-0.5.9}/tests/conftest.py +0 -0
  81. {webbee-0.5.7 → webbee-0.5.9}/tests/test_account.py +0 -0
  82. {webbee-0.5.7 → webbee-0.5.9}/tests/test_active_sessions.py +0 -0
  83. {webbee-0.5.7 → webbee-0.5.9}/tests/test_audit_2026_07_25_findings.py +0 -0
  84. {webbee-0.5.7 → webbee-0.5.9}/tests/test_cli.py +0 -0
  85. {webbee-0.5.7 → webbee-0.5.9}/tests/test_clipboard.py +0 -0
  86. {webbee-0.5.7 → webbee-0.5.9}/tests/test_clipboard_read.py +0 -0
  87. {webbee-0.5.7 → webbee-0.5.9}/tests/test_coding_context.py +0 -0
  88. {webbee-0.5.7 → webbee-0.5.9}/tests/test_commands.py +0 -0
  89. {webbee-0.5.7 → webbee-0.5.9}/tests/test_config.py +0 -0
  90. {webbee-0.5.7 → webbee-0.5.9}/tests/test_cpc_contract_stable.py +0 -0
  91. {webbee-0.5.7 → webbee-0.5.9}/tests/test_cpc_outline.py +0 -0
  92. {webbee-0.5.7 → webbee-0.5.9}/tests/test_cross_loop_token.py +0 -0
  93. {webbee-0.5.7 → webbee-0.5.9}/tests/test_details.py +0 -0
  94. {webbee-0.5.7 → webbee-0.5.9}/tests/test_events.py +0 -0
  95. {webbee-0.5.7 → webbee-0.5.9}/tests/test_freeze_fix.py +0 -0
  96. {webbee-0.5.7 → webbee-0.5.9}/tests/test_home.py +0 -0
  97. {webbee-0.5.7 → webbee-0.5.9}/tests/test_home_view.py +0 -0
  98. {webbee-0.5.7 → webbee-0.5.9}/tests/test_home_view_mouse.py +0 -0
  99. {webbee-0.5.7 → webbee-0.5.9}/tests/test_inspect_page.py +0 -0
  100. {webbee-0.5.7 → webbee-0.5.9}/tests/test_instance_lock.py +0 -0
  101. {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_chunker.py +0 -0
  102. {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_embed.py +0 -0
  103. {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_graph.py +0 -0
  104. {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_no_numpy.py +0 -0
  105. {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_query.py +0 -0
  106. {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_store.py +0 -0
  107. {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_vectors.py +0 -0
  108. {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_watch.py +0 -0
  109. {webbee-0.5.7 → webbee-0.5.9}/tests/test_launch.py +0 -0
  110. {webbee-0.5.7 → webbee-0.5.9}/tests/test_marathon.py +0 -0
  111. {webbee-0.5.7 → webbee-0.5.9}/tests/test_mode_store.py +0 -0
  112. {webbee-0.5.7 → webbee-0.5.9}/tests/test_native_files.py +0 -0
  113. {webbee-0.5.7 → webbee-0.5.9}/tests/test_newtab_mode.py +0 -0
  114. {webbee-0.5.7 → webbee-0.5.9}/tests/test_packaging.py +0 -0
  115. {webbee-0.5.7 → webbee-0.5.9}/tests/test_queue_manage_0337.py +0 -0
  116. {webbee-0.5.7 → webbee-0.5.9}/tests/test_reflow.py +0 -0
  117. {webbee-0.5.7 → webbee-0.5.9}/tests/test_render.py +0 -0
  118. {webbee-0.5.7 → webbee-0.5.9}/tests/test_repl.py +0 -0
  119. {webbee-0.5.7 → webbee-0.5.9}/tests/test_repo.py +0 -0
  120. {webbee-0.5.7 → webbee-0.5.9}/tests/test_resilient_http.py +0 -0
  121. {webbee-0.5.7 → webbee-0.5.9}/tests/test_session.py +0 -0
  122. {webbee-0.5.7 → webbee-0.5.9}/tests/test_sessions.py +0 -0
  123. {webbee-0.5.7 → webbee-0.5.9}/tests/test_sizing.py +0 -0
  124. {webbee-0.5.7 → webbee-0.5.9}/tests/test_slots.py +0 -0
  125. {webbee-0.5.7 → webbee-0.5.9}/tests/test_steer.py +0 -0
  126. {webbee-0.5.7 → webbee-0.5.9}/tests/test_stream.py +0 -0
  127. {webbee-0.5.7 → webbee-0.5.9}/tests/test_tab_bar_stability.py +0 -0
  128. {webbee-0.5.7 → webbee-0.5.9}/tests/test_tab_restore_0337.py +0 -0
  129. {webbee-0.5.7 → webbee-0.5.9}/tests/test_tabs.py +0 -0
  130. {webbee-0.5.7 → webbee-0.5.9}/tests/test_terminal_ux_0336.py +0 -0
  131. {webbee-0.5.7 → webbee-0.5.9}/tests/test_thread.py +0 -0
  132. {webbee-0.5.7 → webbee-0.5.9}/tests/test_tier_invalidation.py +0 -0
  133. {webbee-0.5.7 → webbee-0.5.9}/tests/test_tier_store.py +0 -0
  134. {webbee-0.5.7 → webbee-0.5.9}/tests/test_todo_panel.py +0 -0
  135. {webbee-0.5.7 → webbee-0.5.9}/tests/test_tokens.py +0 -0
  136. {webbee-0.5.7 → webbee-0.5.9}/tests/test_tools_vision_websearch.py +0 -0
  137. {webbee-0.5.7 → webbee-0.5.9}/tests/test_tui.py +0 -0
  138. {webbee-0.5.7 → webbee-0.5.9}/tests/test_tui_hardening.py +0 -0
  139. {webbee-0.5.7 → webbee-0.5.9}/tests/test_update.py +0 -0
  140. {webbee-0.5.7 → webbee-0.5.9}/tests/test_update_badge_0340.py +0 -0
  141. {webbee-0.5.7 → webbee-0.5.9}/tests/test_urlopen.py +0 -0
  142. {webbee-0.5.7 → webbee-0.5.9}/tests/test_version.py +0 -0
  143. {webbee-0.5.7 → webbee-0.5.9}/tests/test_version_badge_corner.py +0 -0
  144. {webbee-0.5.7 → webbee-0.5.9}/tests/test_wallet.py +0 -0
  145. {webbee-0.5.7 → webbee-0.5.9}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.9
4
+
5
+ * **Code questions about your local repo go to the exact code graph in your Imperal vault**: on the kernel's request
6
+ webbee syncs the repo it works in (`intel_sync_local`) and the kernel asks the vault by that repo's key -- who
7
+ calls what by scope rules, uses inside the same file, tests counted apart. When the vault cannot be reached the
8
+ old local index answers and says so (`coarse`).
9
+ * **A remote repo is known by its real path** (a symlinked deploy folder is never synced as a second repo), and a
10
+ code question never waits behind a repo's first sync.
11
+ * **Checkpoints name the paths they changed** -- what imperal-mcp's frame measures an agent's edits by.
12
+ * **Voice**: the recorder stops with its window -- no orphaned ffmpeg filling the disk.
13
+
14
+ ## 0.5.8
15
+
16
+ * **README**: the supported Python versions (3.7 – 3.14) at the top of the page.
17
+
3
18
  ## 0.5.7
4
19
 
5
20
  * **The one-time Python 3.12 setup cannot be steered by the folder it starts in**: uv now runs with
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webbee
3
- Version: 0.5.7
3
+ Version: 0.5.9
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -56,10 +56,12 @@ Description-Content-Type: text/markdown
56
56
  # Webbee 🐝 — The Open-Source ICNLI Coding Agent
57
57
 
58
58
  [![PyPI](https://img.shields.io/pypi/v/webbee)](https://pypi.org/project/webbee/)
59
- [![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776ab?logo=python&logoColor=white)](https://pypi.org/project/webbee/)
59
+ [![Python](https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776ab?logo=python&logoColor=white)](https://pypi.org/project/webbee/)
60
60
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
61
61
  [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7)](https://docs.imperal.io)
62
62
 
63
+ **Python 3.7 – 3.14** · `pip install webbee` works on every one of them.
64
+
63
65
  **Webbee** is the free, open-source terminal coding agent of [Imperal Cloud](https://imperal.io) (the ICNLI AI Cloud OS). It acts directly in your local terminal and workspace: reading, writing, editing, refactoring, and verifying code with deep code intelligence, native multimodal vision, and live cross-platform system capabilities.
64
66
 
65
67
  Webbee is powered by **ICNLI** (*Infrastructure Contextual Natural Language Interface* — [icnli.org](https://icnli.org)), the open protocol that maps natural-language intent to context-aware infrastructure actions.
@@ -100,8 +102,8 @@ Works with **any Python from 3.7**:
100
102
  - **Python 3.7 – 3.9** (macOS ships 3.9, Ubuntu 20.04 has 3.8) — the install is light, and the first
101
103
  `webbee` command sets up the same release on its own Python 3.12, once (about 30–45 s, needs internet;
102
104
  it fetches uv to do it). Every start after that is immediate, and it is the full webbee — code index
103
- and search by meaning included. It lives in `~/.local/share/webbee/runtime`
104
- (`%LOCALAPPDATA%\webbee\runtime` on Windows) and changes nothing else on your system.
105
+ and search by meaning included. It lives in `~/.local/share/webbee/runtime` and changes nothing else on
106
+ your system.
105
107
 
106
108
  <details>
107
109
  <summary>Install problems</summary>
@@ -1,10 +1,12 @@
1
1
  # Webbee 🐝 — The Open-Source ICNLI Coding Agent
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/webbee)](https://pypi.org/project/webbee/)
4
- [![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776ab?logo=python&logoColor=white)](https://pypi.org/project/webbee/)
4
+ [![Python](https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776ab?logo=python&logoColor=white)](https://pypi.org/project/webbee/)
5
5
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
6
6
  [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7)](https://docs.imperal.io)
7
7
 
8
+ **Python 3.7 – 3.14** · `pip install webbee` works on every one of them.
9
+
8
10
  **Webbee** is the free, open-source terminal coding agent of [Imperal Cloud](https://imperal.io) (the ICNLI AI Cloud OS). It acts directly in your local terminal and workspace: reading, writing, editing, refactoring, and verifying code with deep code intelligence, native multimodal vision, and live cross-platform system capabilities.
9
11
 
10
12
  Webbee is powered by **ICNLI** (*Infrastructure Contextual Natural Language Interface* — [icnli.org](https://icnli.org)), the open protocol that maps natural-language intent to context-aware infrastructure actions.
@@ -45,8 +47,8 @@ Works with **any Python from 3.7**:
45
47
  - **Python 3.7 – 3.9** (macOS ships 3.9, Ubuntu 20.04 has 3.8) — the install is light, and the first
46
48
  `webbee` command sets up the same release on its own Python 3.12, once (about 30–45 s, needs internet;
47
49
  it fetches uv to do it). Every start after that is immediate, and it is the full webbee — code index
48
- and search by meaning included. It lives in `~/.local/share/webbee/runtime`
49
- (`%LOCALAPPDATA%\webbee\runtime` on Windows) and changes nothing else on your system.
50
+ and search by meaning included. It lives in `~/.local/share/webbee/runtime` and changes nothing else on
51
+ your system.
50
52
 
51
53
  <details>
52
54
  <summary>Install problems</summary>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.5.7"
3
+ version = "0.5.9"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = "0.5.9"
@@ -107,7 +107,8 @@ class ShadowGit:
107
107
 
108
108
  def checkpoint(self, label: str = "", force: bool = False) -> "dict | None":
109
109
  """Commit the workspace state to the shadow history.
110
- {"id","n","label","changed"} or None when unavailable/failed.
110
+ {"id","n","label","changed","paths"} or None when unavailable/failed --
111
+ ``paths``: the workspace paths this snapshot changed (sorted; [] when none).
111
112
  `force=True` always records a new checkpoint even with nothing dirty
112
113
  -- used by rollback() for its mandatory pre-rollback safety snapshot,
113
114
  which must exist even when the workspace already matches HEAD."""
@@ -117,10 +118,11 @@ class ShadowGit:
117
118
  if self._git("add", "-A").returncode != 0:
118
119
  return None # a partial add must never masquerade as a snapshot
119
120
  dirty = self._git("status", "--porcelain").stdout.strip()
121
+ paths = sorted({ln[3:].split(" -> ")[-1].strip().strip('"') for ln in dirty.splitlines() if len(ln) > 3})
120
122
  head = self._git("rev-parse", "--short", "HEAD")
121
123
  if not dirty and head.returncode == 0 and not force:
122
124
  return {"id": head.stdout.strip(), "n": self._count(),
123
- "label": "(no changes)", "changed": False}
125
+ "label": "(no changes)", "changed": False, "paths": []}
124
126
  msg = (label or "checkpoint").strip()[:200] or "checkpoint"
125
127
  args = ["commit", "-m", msg, "--no-verify"]
126
128
  if head.returncode != 0 or not dirty:
@@ -131,7 +133,7 @@ class ShadowGit:
131
133
  sha = self._git("rev-parse", "--short", "HEAD").stdout.strip()
132
134
  n = self._count() + 1
133
135
  self._git("update-ref", f"refs/webbee/cp-{n}", "HEAD")
134
- return {"id": sha, "n": n, "label": msg, "changed": True}
136
+ return {"id": sha, "n": n, "label": msg, "changed": True, "paths": paths}
135
137
  except Exception:
136
138
  return None
137
139
 
@@ -13,7 +13,7 @@ from webbee.frames import _is_foreign_frame, render_foreign_frame
13
13
  # Verbs that only read: the kernel never asks consent for them (a verified read, core.action_effect); a
14
14
  # confirm_request naming one is answered yes here without a prompt (code graph spec §4.8: index reads
15
15
  # never ask -- intel_sync_remote lists and reads a repo over SFTP, writes nothing, runs nothing there).
16
- READ_VERBS = frozenset({"intel_sync_remote"})
16
+ READ_VERBS = frozenset({"intel_sync_remote", "intel_sync_local"})
17
17
 
18
18
 
19
19
  async def handle_confirm_request(frame: dict, mode: str, ask_consent) -> dict:
@@ -278,7 +278,7 @@ class IntelService:
278
278
  if fresh_ids:
279
279
  self.vectors.add(fresh_ids, fresh_vecs)
280
280
 
281
- def _sync_vault(self, rels=None) -> dict:
281
+ def _sync_vault(self, rels=None, history: bool = True) -> dict:
282
282
  """The workspace (``rels=None``: every indexable file, with the git history's co-change links) or the
283
283
  changed files into the vault. Fail-soft: a cloud outage never touches the local index."""
284
284
  factory = self.vault_client_factory
@@ -309,7 +309,7 @@ class IntelService:
309
309
  "machine_kind": "local", "git": local_git_identity(self.root),
310
310
  "local_key": compute_repo_key(self.root), "listing_complete": rels is None}
311
311
  listing = _local_listing(self.root, rels)
312
- measured = co_change_pairs(self.root) if rels is None else None
312
+ measured = co_change_pairs(self.root) if rels is None and history else None
313
313
  out = asyncio.run(vault_sync(factory(), meta, listing, read, measured))
314
314
  except Exception as exc:
315
315
  log.warning("code sync to the vault failed: %s", exc)
@@ -323,6 +323,22 @@ class IntelService:
323
323
  return out
324
324
 
325
325
 
326
+ def sync_now(self) -> dict:
327
+ """The kernel asks before a code question (frame phase A): the workspace reaches the vault now -- a full
328
+ listing, so an edit no watcher reported (a shell edit) is sent too; the vault's plan picks the changed files.
329
+ The git history is not re-read. ``{repo_key, synced_at, ...counts}`` or ``{ok: False, content}``."""
330
+ if self.vault_client_factory is None:
331
+ return {"ok": False, "content": "this session cannot reach the cloud"}
332
+ if not self.last_sync and not self._sync_lock.acquire(blocking=False):
333
+ # the repo's first sync (a whole repo: extract + embed) is still running: never wait behind it
334
+ return {"ok": False, "content": "the first code sync of this repo is still running"}
335
+ if not self.last_sync:
336
+ self._sync_lock.release()
337
+ out = self._sync_vault(None, history=False)
338
+ if not out.get("repo_key"):
339
+ return {"ok": False, "content": "the code sync to the vault failed"}
340
+ return {**out, "synced_at": time.time()}
341
+
326
342
  def sync_vault(self, rels=None) -> None:
327
343
  """Off the caller's thread: the build and the watcher never wait for the cloud."""
328
344
  if self.vault_client_factory is None:
@@ -10,6 +10,7 @@ Enterprise Grade Multi-OS popups with:
10
10
  """
11
11
  from __future__ import annotations
12
12
 
13
+ import atexit
13
14
  import itertools
14
15
  import os
15
16
  import re
@@ -223,18 +224,56 @@ def get_macos_builtin_audio_device() -> str:
223
224
  return ":0"
224
225
 
225
226
 
227
+ # A voice note never outlives this, even if every stop path fails: ffmpeg ends itself (-t).
228
+ MAX_VOICE_NOTE_SECONDS = 900
229
+
230
+
226
231
  def get_system_audio_capture_args(output_path: str) -> List[str]:
227
- """Build cross-platform FFmpeg audio recording CLI arguments for macOS, Linux, and Windows."""
232
+ """Build cross-platform FFmpeg audio recording CLI arguments for macOS, Linux, and Windows.
233
+
234
+ -nostdin: ffmpeg never reads the terminal (the TUI owns the keys); -t caps the length."""
228
235
  if sys.platform == "darwin":
229
236
  dev = get_macos_builtin_audio_device()
230
- return ["ffmpeg", "-y", "-f", "avfoundation", "-i", dev, "-ar", "16000", "-ac", "1", output_path]
237
+ src = ["-f", "avfoundation", "-i", dev, "-ar", "16000", "-ac", "1"]
231
238
  elif sys.platform.startswith("linux"):
232
239
  if shutil.which("pactl"):
233
- return ["ffmpeg", "-y", "-f", "pulse", "-i", "default", "-ar", "16000", "-ac", "1", output_path]
234
- return ["ffmpeg", "-y", "-f", "alsa", "-i", "default", "-ar", "16000", "-ac", "1", output_path]
240
+ src = ["-f", "pulse", "-i", "default", "-ar", "16000", "-ac", "1"]
241
+ else:
242
+ src = ["-f", "alsa", "-i", "default", "-ar", "16000", "-ac", "1"]
235
243
  elif sys.platform in ("win32", "cygwin"):
236
- return ["ffmpeg", "-y", "-f", "dshow", "-i", "audio=virtual-audio-device", "-ar", "16000", "-ac", "1", output_path]
237
- return ["ffmpeg", "-y", "-i", "default", output_path]
244
+ src = ["-f", "dshow", "-i", "audio=virtual-audio-device", "-ar", "16000", "-ac", "1"]
245
+ else:
246
+ src = ["-i", "default"]
247
+ return ["ffmpeg", "-nostdin", "-y", *src, "-t", str(MAX_VOICE_NOTE_SECONDS), output_path]
248
+
249
+
250
+ # Every recorder still running; atexit stops them so a quit Webbee leaves no microphone open.
251
+ _LIVE_RECORDERS: set = set()
252
+
253
+
254
+ def stop_recorder(proc: Optional[subprocess.Popen]) -> None:
255
+ """Stop one ffmpeg recorder: SIGTERM (ffmpeg finalizes the file), SIGKILL if it hangs."""
256
+ if proc is None:
257
+ return
258
+ _LIVE_RECORDERS.discard(proc)
259
+ try:
260
+ if proc.poll() is None:
261
+ proc.terminate()
262
+ try:
263
+ proc.wait(timeout=2)
264
+ except subprocess.TimeoutExpired:
265
+ proc.kill()
266
+ proc.wait(timeout=2)
267
+ except Exception:
268
+ pass
269
+
270
+
271
+ def stop_all_recorders() -> None:
272
+ for proc in list(_LIVE_RECORDERS):
273
+ stop_recorder(proc)
274
+
275
+
276
+ atexit.register(stop_all_recorders)
238
277
 
239
278
 
240
279
  def transcribe_audio_file(audio_path: str, api_url: str = "", token: str = "") -> str:
@@ -397,10 +436,13 @@ def create_mic_dialog(
397
436
  voice_path = os.path.join(voice_dir, voice_filename)
398
437
 
399
438
  rec_proc: Optional[subprocess.Popen] = None
439
+ sent = False
400
440
 
401
441
  try:
402
442
  args = get_system_audio_capture_args(voice_path)
403
- rec_proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
443
+ rec_proc = subprocess.Popen(args, stdin=subprocess.DEVNULL,
444
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
445
+ _LIVE_RECORDERS.add(rec_proc)
404
446
  except Exception:
405
447
  pass
406
448
 
@@ -417,18 +459,13 @@ def create_mic_dialog(
417
459
  anim_ctrl = FormattedTextControl(_anim_text)
418
460
 
419
461
  def _stop_rec() -> int:
420
- nonlocal rec_proc
421
462
  elapsed = max(1, int(time.time() - start_time))
422
- if rec_proc is not None:
423
- try:
424
- if rec_proc.poll() is None:
425
- rec_proc.terminate()
426
- rec_proc.wait(timeout=2)
427
- except Exception:
428
- pass
463
+ stop_recorder(rec_proc)
429
464
  return elapsed
430
465
 
431
466
  def _handle_send():
467
+ nonlocal sent
468
+ sent = True
432
469
  rec_seconds = _stop_rec()
433
470
  on_close()
434
471
 
@@ -481,13 +518,18 @@ def create_mic_dialog(
481
518
  t = threading.Thread(target=_worker, daemon=True)
482
519
  t.start()
483
520
 
484
- def _handle_cancel():
521
+ def _discard():
522
+ if sent:
523
+ return
485
524
  _stop_rec()
486
525
  try:
487
526
  if os.path.exists(voice_path):
488
527
  os.remove(voice_path)
489
528
  except Exception:
490
529
  pass
530
+
531
+ def _handle_cancel():
532
+ _discard()
491
533
  on_close()
492
534
 
493
535
  # EXACTLY TWO BUTTONS: Send and Cancel matching all other dialog layouts
@@ -505,13 +547,17 @@ def create_mic_dialog(
505
547
  cols = app.output.get_size()[1] if (app and app.output) else 80
506
548
  dialog_width = Dimension(min=36, max=76, preferred=min(68, max(36, cols - 6)))
507
549
 
508
- return Dialog(
550
+ dialog = Dialog(
509
551
  title="Voice Recording (STT)",
510
552
  body=HSplit(all_elements),
511
553
  buttons=[],
512
554
  width=dialog_width,
513
555
  modal=True,
514
556
  )
557
+ # The host calls dispose() whenever the dialog goes away (Esc, another modal) -- not only
558
+ # via the two buttons -- so the microphone is never left recording.
559
+ dialog.dispose = _discard
560
+ return dialog
515
561
 
516
562
 
517
563
  def create_session_menu_dialog(
@@ -768,6 +768,24 @@ class LocalToolExecutor:
768
768
  return []
769
769
 
770
770
  def _cpc(self, verb: str, a: dict) -> dict:
771
+ out = self._cpc_local(verb, a)
772
+ # frame phase A: the kernel asks the vault graph first; this local index is the fallback -- links by name
773
+ if isinstance(out, dict) and out.get("ok", True) is not False:
774
+ out = {**out, "coarse": "vault graph unavailable: links by name"}
775
+ return out
776
+
777
+ def _t_intel_sync_local(self, a: dict) -> dict:
778
+ """The workspace into the vault's code graph now (frame phase A): the kernel asks before a local code
779
+ question when the vault's copy is behind the last edit. A read: nothing is written or run."""
780
+ if self.indexer is None or not hasattr(self.indexer, "sync_now"):
781
+ return {"ok": False, "content": "intel_sync_local: no code index in this session"}
782
+ out = self.indexer.sync_now()
783
+ if out.get("ok") is False:
784
+ return {"ok": False, "content": f"intel_sync_local: {out.get('content')}"}
785
+ counts = {k: v for k, v in out.items() if k not in ("repo_key", "synced_at")}
786
+ return {"ok": True, "content": {"repo_key": out["repo_key"], "synced_at": out["synced_at"], "counts": counts}}
787
+
788
+ def _cpc_local(self, verb: str, a: dict) -> dict:
771
789
  if self.indexer is None:
772
790
  return {"ok": False, "content": "intel not available; install webbee[intel]"}
773
791
  from webbee.intel import query
@@ -1136,6 +1154,10 @@ async def remote_sync(client, host: str, root: str, paths: list) -> dict:
1136
1154
  return {"repo_key": "", "partial_reason": why, "content": f"{host}: {why}"}
1137
1155
  try:
1138
1156
  repo_root = await ci.sftp_repo_root(sftp, root)
1157
+ try: # the repo's identity is its real path: a symlinked dir names the release it points into
1158
+ real_root = str(await sftp.realpath(repo_root) or repo_root) if hasattr(sftp, "realpath") else repo_root
1159
+ except Exception:
1160
+ real_root = repo_root
1139
1161
  meta_files = (await ci.sftp_read_meta(sftp, repo_root))["files"]
1140
1162
  if paths:
1141
1163
  listing = []
@@ -1154,7 +1176,7 @@ async def remote_sync(client, host: str, root: str, paths: list) -> dict:
1154
1176
  meta = {"machine": {"fingerprints": fps,
1155
1177
  "machine_id": meta_files.get("/etc/machine-id", b"").decode("utf-8", "replace").strip(),
1156
1178
  "label": host},
1157
- "root": repo_root, "root_path": repo_root, "os": tree["os"], "source": "system_ssh", "ref": host,
1179
+ "root": real_root, "root_path": real_root, "os": tree["os"], "source": "system_ssh", "ref": host,
1158
1180
  "machine_kind": "ssh", "git": ci.git_identity(meta_files), "listing_complete": not paths}
1159
1181
  if tree.get("partial"):
1160
1182
  meta["partial_listing"] = tree["partial"]
@@ -1886,7 +1886,19 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None,
1886
1886
  if app is not None:
1887
1887
  app.invalidate()
1888
1888
 
1889
+ def _dispose_modal(dialog):
1890
+ # A dialog that holds a resource (the mic recorder) releases it however it is closed.
1891
+ dispose = getattr(dialog, "dispose", None)
1892
+ if callable(dispose):
1893
+ try:
1894
+ dispose()
1895
+ except Exception:
1896
+ pass
1897
+
1889
1898
  def _set_modal_active(dialog):
1899
+ prev = _active_modal.get("dialog")
1900
+ if prev is not None and prev is not dialog:
1901
+ _dispose_modal(prev)
1890
1902
  _active_modal["dialog"] = dialog
1891
1903
  from prompt_toolkit.application import get_app_or_none
1892
1904
  app = get_app_or_none()
@@ -1900,7 +1912,10 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None,
1900
1912
  _invalidate()
1901
1913
 
1902
1914
  def _close_modal():
1915
+ prev = _active_modal.get("dialog")
1903
1916
  _active_modal["dialog"] = None
1917
+ if prev is not None:
1918
+ _dispose_modal(prev)
1904
1919
  _hover_on["v"] = None
1905
1920
  # Guarantee buffer is 100% clean of any mouse escape residue on modal close
1906
1921
  buf.text = scrub_mouse_residue(buf.text)
@@ -276,3 +276,17 @@ def test_describe_surfaces_paused_autocheckpoint(tmp_path):
276
276
  out = sg.describe()
277
277
  assert out.startswith("⚠ Auto-checkpointing is paused")
278
278
  assert "cp-1" in out # the list still follows the warning
279
+
280
+
281
+ def test_checkpoint_names_the_changed_paths(tmp_path):
282
+ """Phase B Task 10: a snapshot says which paths it changed (imperal-mcp measures an agent's edits by it)."""
283
+ from webbee.checkpoints import ShadowGit
284
+ ws = tmp_path / "ws"
285
+ ws.mkdir()
286
+ (ws / "a.py").write_text("x=1\n")
287
+ sg = ShadowGit(str(ws), "k", cache_dir=str(tmp_path / "c"))
288
+ assert sg.ensure()
289
+ assert sg.checkpoint("base", force=True)["paths"] == ["a.py"]
290
+ (ws / "a.py").write_text("x=2\n")
291
+ assert sg.checkpoint("next")["paths"] == ["a.py"]
292
+ assert sg.checkpoint("same")["paths"] == []
@@ -287,3 +287,42 @@ def test_a_link_leading_out_of_the_workspace_is_never_read(tmp_path, monkeypatch
287
287
  sent = [c[1] for c in cloud.calls if "files" in c[1]]
288
288
  assert sent and all(f["path"] != "leak.py" for c in sent for f in c["files"])
289
289
  assert "outside the workspace" not in repr(cloud.calls)
290
+
291
+
292
+ def test_sync_now_sends_an_edit_the_watcher_has_not_reported(tmp_path, monkeypatch):
293
+ """frame phase A: the kernel asks for a sync before a code question -- the edit reaches the vault at once,
294
+ even one no watcher reported (a shell edit), and without re-reading the git history."""
295
+ (tmp_path / "a.py").write_text("def alpha():\n return 1\n")
296
+ svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
297
+ _synced(svc, monkeypatch)
298
+ cloud = _Cloud()
299
+ svc.build()
300
+ svc.attach_vault(lambda: cloud)
301
+ cloud.calls.clear()
302
+ cloud.have = {"a.py"}
303
+ (tmp_path / "b.py").write_text("def beta():\n return 2\n") # a shell edit, never reported
304
+ out = svc.sync_now()
305
+ assert out["repo_key"] and out["synced_at"] > 0
306
+ assert [f["path"] for f in cloud.calls[1][1]["files"]] == ["b.py"]
307
+ assert "measured" not in cloud.calls[1][1]
308
+
309
+
310
+ def test_sync_now_without_cloud_says_so(tmp_path):
311
+ svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
312
+ assert svc.sync_now() == {"ok": False, "content": "this session cannot reach the cloud"}
313
+
314
+
315
+ def test_sync_now_while_the_first_sync_runs_answers_at_once(tmp_path, monkeypatch):
316
+ """frame phase A (review I7): a big repo's first sync is still running -- the question goes coarse at once
317
+ instead of waiting behind it."""
318
+ (tmp_path / "a.py").write_text("def alpha():\n return 1\n")
319
+ svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
320
+ svc.build()
321
+ svc.vault_client_factory = lambda: _Cloud()
322
+ svc.last_sync = {}
323
+ assert svc._sync_lock.acquire(blocking=False) # the boot-time first sync holds the lock
324
+ try:
325
+ out = svc.sync_now()
326
+ finally:
327
+ svc._sync_lock.release()
328
+ assert out == {"ok": False, "content": "the first code sync of this repo is still running"}
@@ -73,7 +73,46 @@ def test_file_picker_dialog_callbacks():
73
73
  assert dialog.title == "Attach File / Document"
74
74
 
75
75
 
76
- def test_mic_dialog_callbacks():
76
+ class _FakeRecorder:
77
+ """Stands in for the ffmpeg Popen -- tests never open the real microphone."""
78
+ def __init__(self, args, **kw):
79
+ self.args, self.kw = args, kw
80
+ self.alive = True
81
+ self.signals = []
82
+ with open(args[-1], "wb") as f:
83
+ f.write(b"RIFF")
84
+
85
+ def poll(self):
86
+ return None if self.alive else 0
87
+
88
+ def terminate(self):
89
+ self.signals.append("TERM")
90
+ self.alive = False
91
+
92
+ def kill(self):
93
+ self.signals.append("KILL")
94
+ self.alive = False
95
+
96
+ def wait(self, timeout=None):
97
+ return 0
98
+
99
+
100
+ @pytest.fixture
101
+ def fake_recorder(monkeypatch, tmp_path):
102
+ import webbee.modals as m
103
+ made = []
104
+
105
+ def _popen(args, **kw):
106
+ made.append(_FakeRecorder(args, **kw))
107
+ return made[-1]
108
+
109
+ monkeypatch.setattr(m.subprocess, "Popen", _popen)
110
+ monkeypatch.setattr(m, "get_macos_builtin_audio_device", lambda: ":0")
111
+ return made, str(tmp_path)
112
+
113
+
114
+ def test_mic_dialog_callbacks(fake_recorder):
115
+ _, voice_dir = fake_recorder
77
116
  transcribed = []
78
117
  sent = []
79
118
  closed = []
@@ -82,8 +121,80 @@ def test_mic_dialog_callbacks():
82
121
  on_transcribe=lambda t: transcribed.append(t),
83
122
  on_send_direct=lambda t: sent.append(t),
84
123
  on_close=lambda: closed.append(True),
124
+ voice_dir=voice_dir,
85
125
  )
86
126
  assert dialog.title == "Voice Recording (STT)"
127
+ dialog.dispose()
128
+
129
+
130
+ def test_mic_dialog_dispose_stops_recording_and_drops_the_file(fake_recorder):
131
+ # Esc / another modal closes the dialog through dispose(), not the buttons:
132
+ # the recorder must stop and the unsent audio must go.
133
+ made, voice_dir = fake_recorder
134
+ dialog = create_mic_dialog(on_transcribe=lambda t: None, on_send_direct=lambda t: None,
135
+ on_close=lambda: None, voice_dir=voice_dir)
136
+ rec = made[0]
137
+ assert rec.kw.get("stdin") is not None # never the terminal's stdin
138
+ dialog.dispose()
139
+ assert rec.signals == ["TERM"] and not rec.alive
140
+ assert os.listdir(voice_dir) == []
141
+ dialog.dispose() # idempotent
142
+ assert rec.signals == ["TERM"]
143
+
144
+
145
+ def test_stop_recorder_kills_a_recorder_that_ignores_term():
146
+ import subprocess
147
+ from webbee.modals import stop_recorder
148
+
149
+ class _Stuck(_FakeRecorder):
150
+ def __init__(self):
151
+ self.alive, self.signals = True, []
152
+
153
+ def terminate(self):
154
+ self.signals.append("TERM") # ignored
155
+
156
+ def wait(self, timeout=None):
157
+ if self.alive:
158
+ raise subprocess.TimeoutExpired("ffmpeg", timeout)
159
+ return 0
160
+
161
+ p = _Stuck()
162
+ stop_recorder(p)
163
+ assert p.signals == ["TERM", "KILL"] and not p.alive
164
+
165
+
166
+ def test_quitting_stops_every_live_recorder(fake_recorder):
167
+ import webbee.modals as m
168
+ made, voice_dir = fake_recorder
169
+ create_mic_dialog(on_transcribe=lambda t: None, on_send_direct=lambda t: None,
170
+ on_close=lambda: None, voice_dir=voice_dir)
171
+ assert made[0] in m._LIVE_RECORDERS
172
+ m.stop_all_recorders() # what atexit runs
173
+ assert not made[0].alive and not m._LIVE_RECORDERS
174
+
175
+
176
+ def test_mic_send_keeps_the_audio_for_transcription(fake_recorder, monkeypatch):
177
+ import webbee.modals as m
178
+ made, voice_dir = fake_recorder
179
+ seen = []
180
+ monkeypatch.setattr(m, "transcribe_audio_file",
181
+ lambda path, **kw: seen.append(os.path.exists(path)) or "hello")
182
+ buttons = {}
183
+ real_button = m.make_clean_button
184
+ monkeypatch.setattr(m, "make_clean_button",
185
+ lambda text, handler=None, **kw: buttons.setdefault(text, handler) and real_button(text, handler=handler, **kw))
186
+ host = {}
187
+ dialog = create_mic_dialog(on_transcribe=lambda t: None, on_send_direct=lambda t: None,
188
+ on_close=lambda: host["dialog"].dispose(), voice_dir=voice_dir)
189
+ host["dialog"] = dialog
190
+ buttons["▶ Send Voice Note"]()
191
+ import time
192
+ for _ in range(50):
193
+ if seen:
194
+ break
195
+ time.sleep(0.02)
196
+ assert seen == [True] # host's dispose on close did not delete it
197
+ assert not made[0].alive
87
198
 
88
199
 
89
200
  def test_session_menu_dialog_callbacks():
@@ -122,6 +233,8 @@ def test_system_audio_capture_args():
122
233
  assert len(cmd) > 0
123
234
  assert cmd[0] == "ffmpeg"
124
235
  assert "/tmp/test_out.wav" in cmd
236
+ assert "-nostdin" in cmd
237
+ assert cmd[cmd.index("-t") + 1] == "900"
125
238
 
126
239
 
127
240
  def test_modal_input_insulation_prevents_command_line_pollution():
@@ -923,3 +923,40 @@ def test_an_asyncssh_connect_reports_every_key_of_the_host_the_user_trusts(monke
923
923
  monkeypatch.setattr(asyncssh, "connect", connect)
924
924
  sftp, conn, fps, why = asyncio.run(t._open_sftp("whm-jump"))
925
925
  assert sftp == "sftp" and why == "" and fps == ["SHA256:ecdsa", "SHA256:ed", "SHA256:rsa"]
926
+
927
+
928
+ def test_intel_sync_local_returns_the_vault_key(tmp_path):
929
+ class _Svc:
930
+ def sync_now(self):
931
+ return {"repo_key": "rk1", "synced_at": 5.0, "files": 1}
932
+ ex = LocalToolExecutor(str(tmp_path), indexer=_Svc())
933
+ out = ex._t_intel_sync_local({"root": str(tmp_path)})
934
+ assert out == {"ok": True, "content": {"repo_key": "rk1", "synced_at": 5.0, "counts": {"files": 1}}}
935
+
936
+
937
+ def test_local_code_answers_marked_coarse(tmp_path):
938
+ from webbee.intel.service import IntelService
939
+ (tmp_path / "a.py").write_text("def alpha():\n return 1\n")
940
+ svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
941
+ svc.build()
942
+ ex = LocalToolExecutor(str(tmp_path), indexer=svc)
943
+ out = ex._t_impact_of_change({"symbols": ["alpha"], "_local_index": True})
944
+ assert out["coarse"] == "vault graph unavailable: links by name"
945
+
946
+
947
+ def test_intel_sync_local_needs_no_consent():
948
+ from webbee.consent import READ_VERBS
949
+ assert "intel_sync_local" in READ_VERBS
950
+
951
+
952
+ def test_a_symlinked_remote_repo_is_known_by_its_real_path(monkeypatch):
953
+ """frame phase A (review I2): the repo's identity is the real path the server reports."""
954
+ sftp = _Sftp(WIN_TREE, WIN_FILES)
955
+
956
+ async def realpath(p):
957
+ return "/C:/real/proj" if p == "/C:/u/proj" else p
958
+ sftp.realpath = realpath
959
+ ex, cloud = _remote(monkeypatch, sftp)
960
+ ex.run("intel_sync_remote", {"host": "winbox", "root": "/C:/u/proj/Pkg"})
961
+ meta = cloud.calls[0][1]["meta"]
962
+ assert meta["root"] == meta["root_path"] == "/C:/real/proj"
@@ -1 +0,0 @@
1
- __version__ = "0.5.7"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes