webbee 0.6.4__tar.gz → 0.6.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 (148) hide show
  1. {webbee-0.6.4 → webbee-0.6.6}/CHANGELOG.md +11 -5
  2. {webbee-0.6.4 → webbee-0.6.6}/PKG-INFO +1 -1
  3. {webbee-0.6.4 → webbee-0.6.6}/pyproject.toml +1 -1
  4. webbee-0.6.6/src/webbee/__init__.py +1 -0
  5. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/autoupdate.py +98 -6
  6. {webbee-0.6.4 → webbee-0.6.6}/tests/test_autoupdate.py +51 -1
  7. webbee-0.6.4/src/webbee/__init__.py +0 -1
  8. {webbee-0.6.4 → webbee-0.6.6}/.github/workflows/publish.yml +0 -0
  9. {webbee-0.6.4 → webbee-0.6.6}/.gitignore +0 -0
  10. {webbee-0.6.4 → webbee-0.6.6}/LICENSE +0 -0
  11. {webbee-0.6.4 → webbee-0.6.6}/README.md +0 -0
  12. {webbee-0.6.4 → webbee-0.6.6}/install.sh +0 -0
  13. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/__main__.py +0 -0
  14. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/_launch.py +0 -0
  15. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/account.py +0 -0
  16. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/active_sessions.py +0 -0
  17. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/banner_art.py +0 -0
  18. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/boot.py +0 -0
  19. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/browser.py +0 -0
  20. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/checkpoints.py +0 -0
  21. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/cli.py +0 -0
  22. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/clipboard.py +0 -0
  23. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/clipboard_read.py +0 -0
  24. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/clipboard_session.py +0 -0
  25. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/coding_context.py +0 -0
  26. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/commands.py +0 -0
  27. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/config.py +0 -0
  28. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/consent.py +0 -0
  29. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/details.py +0 -0
  30. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/events.py +0 -0
  31. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/frames.py +0 -0
  32. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/home.py +0 -0
  33. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/home_view.py +0 -0
  34. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/http.py +0 -0
  35. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/instance_lock.py +0 -0
  36. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/intel/__init__.py +0 -0
  37. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/intel/graph.py +0 -0
  38. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/intel/outline.py +0 -0
  39. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/intel/query.py +0 -0
  40. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/intel/service.py +0 -0
  41. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/intel/store.py +0 -0
  42. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/intel/vectors.py +0 -0
  43. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/intel/watch.py +0 -0
  44. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/media.py +0 -0
  45. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/modals.py +0 -0
  46. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/mode_store.py +0 -0
  47. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/native_files.py +0 -0
  48. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/native_read.py +0 -0
  49. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/newtab_mode.py +0 -0
  50. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/output_pane.py +0 -0
  51. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/pricing_constants.py +0 -0
  52. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/queue_panel.py +0 -0
  53. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/reflow.py +0 -0
  54. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/remote.py +0 -0
  55. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/render.py +0 -0
  56. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/repl.py +0 -0
  57. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/repo.py +0 -0
  58. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/selection.py +0 -0
  59. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/session.py +0 -0
  60. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/sessions.py +0 -0
  61. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/sizing.py +0 -0
  62. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/slots.py +0 -0
  63. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/steer.py +0 -0
  64. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/stream.py +0 -0
  65. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/tab_store.py +0 -0
  66. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/tabs.py +0 -0
  67. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/thread.py +0 -0
  68. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/tier_store.py +0 -0
  69. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/todo_panel.py +0 -0
  70. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/tokens.py +0 -0
  71. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/tools.py +0 -0
  72. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/tui.py +0 -0
  73. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/update.py +0 -0
  74. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/urlopen.py +0 -0
  75. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/wallet.py +0 -0
  76. {webbee-0.6.4 → webbee-0.6.6}/src/webbee/worktrees.py +0 -0
  77. {webbee-0.6.4 → webbee-0.6.6}/tests/__init__.py +0 -0
  78. {webbee-0.6.4 → webbee-0.6.6}/tests/conftest.py +0 -0
  79. {webbee-0.6.4 → webbee-0.6.6}/tests/test_account.py +0 -0
  80. {webbee-0.6.4 → webbee-0.6.6}/tests/test_active_sessions.py +0 -0
  81. {webbee-0.6.4 → webbee-0.6.6}/tests/test_audit_2026_07_25_findings.py +0 -0
  82. {webbee-0.6.4 → webbee-0.6.6}/tests/test_checkpoints.py +0 -0
  83. {webbee-0.6.4 → webbee-0.6.6}/tests/test_cli.py +0 -0
  84. {webbee-0.6.4 → webbee-0.6.6}/tests/test_clipboard.py +0 -0
  85. {webbee-0.6.4 → webbee-0.6.6}/tests/test_clipboard_read.py +0 -0
  86. {webbee-0.6.4 → webbee-0.6.6}/tests/test_coding_context.py +0 -0
  87. {webbee-0.6.4 → webbee-0.6.6}/tests/test_commands.py +0 -0
  88. {webbee-0.6.4 → webbee-0.6.6}/tests/test_config.py +0 -0
  89. {webbee-0.6.4 → webbee-0.6.6}/tests/test_cpc_contract_stable.py +0 -0
  90. {webbee-0.6.4 → webbee-0.6.6}/tests/test_cpc_outline.py +0 -0
  91. {webbee-0.6.4 → webbee-0.6.6}/tests/test_cross_loop_token.py +0 -0
  92. {webbee-0.6.4 → webbee-0.6.6}/tests/test_details.py +0 -0
  93. {webbee-0.6.4 → webbee-0.6.6}/tests/test_events.py +0 -0
  94. {webbee-0.6.4 → webbee-0.6.6}/tests/test_freeze_fix.py +0 -0
  95. {webbee-0.6.4 → webbee-0.6.6}/tests/test_home.py +0 -0
  96. {webbee-0.6.4 → webbee-0.6.6}/tests/test_home_view.py +0 -0
  97. {webbee-0.6.4 → webbee-0.6.6}/tests/test_home_view_mouse.py +0 -0
  98. {webbee-0.6.4 → webbee-0.6.6}/tests/test_inspect_page.py +0 -0
  99. {webbee-0.6.4 → webbee-0.6.6}/tests/test_instance_lock.py +0 -0
  100. {webbee-0.6.4 → webbee-0.6.6}/tests/test_intel_chunker.py +0 -0
  101. {webbee-0.6.4 → webbee-0.6.6}/tests/test_intel_embed.py +0 -0
  102. {webbee-0.6.4 → webbee-0.6.6}/tests/test_intel_graph.py +0 -0
  103. {webbee-0.6.4 → webbee-0.6.6}/tests/test_intel_no_numpy.py +0 -0
  104. {webbee-0.6.4 → webbee-0.6.6}/tests/test_intel_query.py +0 -0
  105. {webbee-0.6.4 → webbee-0.6.6}/tests/test_intel_service.py +0 -0
  106. {webbee-0.6.4 → webbee-0.6.6}/tests/test_intel_store.py +0 -0
  107. {webbee-0.6.4 → webbee-0.6.6}/tests/test_intel_vectors.py +0 -0
  108. {webbee-0.6.4 → webbee-0.6.6}/tests/test_intel_watch.py +0 -0
  109. {webbee-0.6.4 → webbee-0.6.6}/tests/test_launch.py +0 -0
  110. {webbee-0.6.4 → webbee-0.6.6}/tests/test_marathon.py +0 -0
  111. {webbee-0.6.4 → webbee-0.6.6}/tests/test_modals_e2e.py +0 -0
  112. {webbee-0.6.4 → webbee-0.6.6}/tests/test_mode_store.py +0 -0
  113. {webbee-0.6.4 → webbee-0.6.6}/tests/test_native_files.py +0 -0
  114. {webbee-0.6.4 → webbee-0.6.6}/tests/test_newtab_mode.py +0 -0
  115. {webbee-0.6.4 → webbee-0.6.6}/tests/test_packaging.py +0 -0
  116. {webbee-0.6.4 → webbee-0.6.6}/tests/test_probe_cost.py +0 -0
  117. {webbee-0.6.4 → webbee-0.6.6}/tests/test_queue_manage_0337.py +0 -0
  118. {webbee-0.6.4 → webbee-0.6.6}/tests/test_reflow.py +0 -0
  119. {webbee-0.6.4 → webbee-0.6.6}/tests/test_render.py +0 -0
  120. {webbee-0.6.4 → webbee-0.6.6}/tests/test_repl.py +0 -0
  121. {webbee-0.6.4 → webbee-0.6.6}/tests/test_repo.py +0 -0
  122. {webbee-0.6.4 → webbee-0.6.6}/tests/test_resilient_http.py +0 -0
  123. {webbee-0.6.4 → webbee-0.6.6}/tests/test_session.py +0 -0
  124. {webbee-0.6.4 → webbee-0.6.6}/tests/test_sessions.py +0 -0
  125. {webbee-0.6.4 → webbee-0.6.6}/tests/test_sizing.py +0 -0
  126. {webbee-0.6.4 → webbee-0.6.6}/tests/test_slots.py +0 -0
  127. {webbee-0.6.4 → webbee-0.6.6}/tests/test_steer.py +0 -0
  128. {webbee-0.6.4 → webbee-0.6.6}/tests/test_stream.py +0 -0
  129. {webbee-0.6.4 → webbee-0.6.6}/tests/test_tab_bar_stability.py +0 -0
  130. {webbee-0.6.4 → webbee-0.6.6}/tests/test_tab_restore_0337.py +0 -0
  131. {webbee-0.6.4 → webbee-0.6.6}/tests/test_tabs.py +0 -0
  132. {webbee-0.6.4 → webbee-0.6.6}/tests/test_terminal_ux_0336.py +0 -0
  133. {webbee-0.6.4 → webbee-0.6.6}/tests/test_thread.py +0 -0
  134. {webbee-0.6.4 → webbee-0.6.6}/tests/test_tier_invalidation.py +0 -0
  135. {webbee-0.6.4 → webbee-0.6.6}/tests/test_tier_store.py +0 -0
  136. {webbee-0.6.4 → webbee-0.6.6}/tests/test_todo_panel.py +0 -0
  137. {webbee-0.6.4 → webbee-0.6.6}/tests/test_tokens.py +0 -0
  138. {webbee-0.6.4 → webbee-0.6.6}/tests/test_tools.py +0 -0
  139. {webbee-0.6.4 → webbee-0.6.6}/tests/test_tools_vision_websearch.py +0 -0
  140. {webbee-0.6.4 → webbee-0.6.6}/tests/test_tui.py +0 -0
  141. {webbee-0.6.4 → webbee-0.6.6}/tests/test_tui_hardening.py +0 -0
  142. {webbee-0.6.4 → webbee-0.6.6}/tests/test_update.py +0 -0
  143. {webbee-0.6.4 → webbee-0.6.6}/tests/test_update_badge_0340.py +0 -0
  144. {webbee-0.6.4 → webbee-0.6.6}/tests/test_urlopen.py +0 -0
  145. {webbee-0.6.4 → webbee-0.6.6}/tests/test_version.py +0 -0
  146. {webbee-0.6.4 → webbee-0.6.6}/tests/test_version_badge_corner.py +0 -0
  147. {webbee-0.6.4 → webbee-0.6.6}/tests/test_wallet.py +0 -0
  148. {webbee-0.6.4 → webbee-0.6.6}/tests/test_worktrees.py +0 -0
@@ -1,11 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## 0.6.4
3
+ ## 0.6.6
4
4
 
5
- * **imperal-mcp 0.8.3 at least**: the prompt hook's frame is composed by the kernel to the size the shell keeps.
6
- Works with the kernel's package 0.8.5 of 2026-10-25: a run started in the background is proven by the read of its
7
- log, every parallel call keeps its result, an expectation is checked on the whole answer, the conversation lens holds
8
- the request being worked on, WORLD takes the code map's commit and leaves old facts of a machine out.
5
+ * **The upgrade's lock is the user's own**: it lives in webbee's data folder (only this user can write it), never in
6
+ the shared temp folder where another user could hold every upgrade back; a lock another user made is ignored.
7
+
8
+ ## 0.6.5
9
+
10
+ * **Two webbees started together never break the install**: the upgrade at the start runs one at a time (a lock per
11
+ user); the second start waits for the first and starts on the release it installed. A pipx install is upgraded in
12
+ place (`pipx runpip`), never deleted and built again; the new release must import before webbee starts on it, else
13
+ it is installed once more (live 2026-10-25: a start in the middle of another's reinstall failed on
14
+ `No module named 'pygments.styles'`).
9
15
 
10
16
  ## 0.6.4
11
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webbee
3
- Version: 0.6.4
3
+ Version: 0.6.6
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.6.4"
3
+ version = "0.6.6"
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.6.6"
@@ -12,6 +12,7 @@ import os
12
12
  import shutil
13
13
  import subprocess
14
14
  import sys
15
+ import time
15
16
 
16
17
  DIST = "webbee"
17
18
  MCP = "imperal-mcp"
@@ -22,6 +23,10 @@ OFF_ENV = "WEBBEE_NO_AUTOUPDATE" # 1: never upgrade at the start (CI,
22
23
  DONE_ENV = "WEBBEE_UPGRADED" # set on the restart after a self-upgrade: never twice in a row
23
24
  LIGHT_ENV = "WEBBEE_LIGHT_PYTHON" # the old Python whose light install runs this release (webbee._launch)
24
25
  STEP_TIMEOUT_S = 300
26
+ # one upgrade at a time per user: two webbees started together reinstalled one venv at once and the restart found it
27
+ # half-written (live 2026-10-25: "No module named 'pygments.styles'"); the second waits for the first
28
+ LOCK_WAIT_S = 2 * STEP_TIMEOUT_S + 60
29
+ LOCK_STALE_S = 3 * STEP_TIMEOUT_S
25
30
  _WINDOWS = os.name == "nt"
26
31
  # `python -c` puts the current folder first on sys.path: a webbee.py there would run in our place (security review).
27
32
  # Dropped before any import -- `-I` is no way out, it also drops a `pip install --user` webbee.
@@ -104,14 +109,18 @@ def self_command(version: str, prefix: str = sys.prefix, executable: str = sys.e
104
109
  light = env.get(LIGHT_ENV)
105
110
  if light: # an old Python runs this release through its light install
106
111
  return [light, "-m", "pip", "install", "--upgrade", "--no-cache-dir", spec]
107
- return _install(tool_of(prefix), spec, uv, pipx, executable)
112
+ return _install(tool_of(prefix), spec, uv, pipx, executable, venv=DIST)
108
113
 
109
114
 
110
- def _install(tool: str, spec: str, uv, pipx, python):
115
+ def _install(tool: str, spec: str, uv, pipx, python, venv: str = ""):
111
116
  """``spec`` installed by ``tool`` -- past its index cache: a release minutes old is missing from a cached index page
112
- (live 2026-10-25: pip listed nothing newer than 0.5.13 while PyPI served 0.6.0)."""
117
+ (live 2026-10-25: pip listed nothing newer than 0.5.13 while PyPI served 0.6.0). A pipx venv that exists is upgraded
118
+ in place (``runpip``): ``install --force`` deletes the venv and builds it again, and a start in that window found
119
+ it half-written (live 2026-10-25)."""
113
120
  if tool == "uv" and uv:
114
121
  return [uv, "tool", "install", "--force", "--refresh", spec]
122
+ if tool == "pipx" and pipx and venv:
123
+ return [pipx, "runpip", venv, "install", "--upgrade", "--no-cache-dir", spec]
115
124
  if tool == "pipx" and pipx:
116
125
  return [pipx, "install", "--force", "--pip-args=--no-cache-dir", spec]
117
126
  if tool == "pip" and python:
@@ -154,7 +163,7 @@ def mcp_command(inst: dict, newest, uv, pipx, wheel=None):
154
163
  return None # PyPI unread: what works stays
155
164
  spec = _spec(MCP_SPEC, MCP, newest, wheel) if newest else (MCP_SPEC if inst else MCP_GIT_SPEC)
156
165
  tool = inst.get("tool") if inst else ("uv" if uv else "pipx" if pipx else "")
157
- return _install(tool, spec, uv, pipx, inst.get("python") if inst else None)
166
+ return _install(tool, spec, uv, pipx, inst.get("python") if inst else None, venv=MCP if inst else "")
158
167
 
159
168
 
160
169
  def restart_command(argv, env=os.environ, executable: str = sys.executable):
@@ -165,6 +174,65 @@ def restart_command(argv, env=os.environ, executable: str = sys.executable):
165
174
  return [executable, "-c", _NO_CWD + "from webbee.cli import main; main()", *argv]
166
175
 
167
176
 
177
+ def _lock_path() -> str:
178
+ """The lock in webbee's own data folder of this user (0700) -- never a shared temp folder, where anyone could make
179
+ it first and hold every upgrade back (security review 2026-10-25)."""
180
+ try:
181
+ from webbee._launch import _home
182
+ base = os.path.dirname(_home())
183
+ except Exception:
184
+ base = os.path.join(os.path.expanduser("~"), ".webbee")
185
+ os.makedirs(base, mode=0o700, exist_ok=True)
186
+ return os.path.join(base, "upgrade.lock")
187
+
188
+
189
+ def _lock(path: str) -> bool:
190
+ """This start's turn to upgrade: the lock file made by it alone (``O_EXCL``, any system); a lock older than any
191
+ upgrade takes is left over from a killed start and is taken over."""
192
+ for _ in range(2):
193
+ try:
194
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
195
+ os.write(fd, f"{os.getpid()} {time.time():.0f}".encode())
196
+ os.close(fd)
197
+ return True
198
+ except FileExistsError:
199
+ try:
200
+ st = os.stat(path)
201
+ if hasattr(os, "getuid") and st.st_uid != os.getuid():
202
+ return True # not this user's: no lock of ours, the upgrade goes on
203
+ if time.time() - st.st_mtime < LOCK_STALE_S:
204
+ return False
205
+ os.remove(path)
206
+ except OSError:
207
+ return False
208
+ except OSError:
209
+ return True # no place for a lock: upgrade as before rather than never
210
+ return False
211
+
212
+
213
+ def _unlock(path: str) -> None:
214
+ try:
215
+ os.remove(path)
216
+ except OSError:
217
+ pass
218
+
219
+
220
+ def _wait_unlocked(path: str, sleep=time.sleep) -> None:
221
+ end = time.time() + LOCK_WAIT_S
222
+ while os.path.exists(path) and time.time() < end:
223
+ sleep(1)
224
+
225
+
226
+ def imports_ok(python: str, run=subprocess.run) -> bool:
227
+ """The installed webbee starts: its modules import in its own Python (a half-written venv fails here, not at the
228
+ user's start)."""
229
+ try:
230
+ return run([python, "-c", _NO_CWD + "import webbee.cli, webbee.repl"], capture_output=True, text=True,
231
+ timeout=60).returncode == 0
232
+ except Exception:
233
+ return False
234
+
235
+
168
236
  def _run(cmd, run=subprocess.run) -> bool:
169
237
  try:
170
238
  return run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
@@ -174,22 +242,43 @@ def _run(cmd, run=subprocess.run) -> bool:
174
242
 
175
243
 
176
244
  def upgrade_before_start(argv, current: str, say=print, fetch=latest, which=safe_which, run=subprocess.run,
177
- execv=os.execv, env=os.environ, wheel=wheel_url) -> list:
245
+ execv=os.execv, env=os.environ, wheel=wheel_url, lock_path=_lock_path,
246
+ sleep=time.sleep) -> list:
178
247
  """Bring webbee and the shell's imperal-mcp to their newest releases, then let the start go on; a self-upgrade
179
248
  starts webbee again on the new release (never returns then). Returns what it did, for the log. Never raises."""
180
249
  did: list = []
250
+ lock = ""
181
251
  try:
182
252
  if env.get(OFF_ENV) == "1":
183
253
  return did
254
+ lock = lock_path() if lock_path else ""
255
+ if lock and not _lock(lock):
256
+ # another webbee is upgrading this install: wait for it, then start on what it installed
257
+ say("🐝 another webbee is updating -- waiting for it ...")
258
+ _wait_unlocked(lock, sleep)
259
+ lock = ""
260
+ newest = fetch(DIST)
261
+ if newest and _ver(newest) > _ver(current) and not env.get(DONE_ENV):
262
+ env[DONE_ENV] = "1"
263
+ again = restart_command(list(argv), env=env)
264
+ if _WINDOWS:
265
+ sys.exit(subprocess.call(again))
266
+ execv(again[0], again)
267
+ return did
184
268
  uv, pipx = find_uv(which), which("pipx")
185
269
  newest = fetch(DIST)
186
270
  if newest and _ver(newest) > _ver(current) and not env.get(DONE_ENV):
187
271
  cmd = self_command(newest, env=env, uv=uv, pipx=pipx, wheel=wheel)
188
272
  if cmd:
189
273
  say(f"🐝 updating webbee {current} -> {newest} ...")
190
- if _run(cmd, run):
274
+ python = env.get(LIGHT_ENV) or ""
275
+ ok = _run(cmd, run) and (python or imports_ok(sys.executable, run) or
276
+ (_run(cmd, run) and imports_ok(sys.executable, run)))
277
+ if ok:
191
278
  did.append(f"webbee {newest}")
192
279
  env[DONE_ENV] = "1" # the restart inherits it: never a second self-upgrade
280
+ _unlock(lock) # exec never returns: the lock goes before it, not in finally
281
+ lock = ""
193
282
  again = restart_command(list(argv), env=env)
194
283
  if _WINDOWS: # no exec on Windows: run it and pass its exit code on
195
284
  sys.exit(subprocess.call(again))
@@ -211,5 +300,8 @@ def upgrade_before_start(argv, current: str, say=print, fetch=latest, which=safe
211
300
  raise
212
301
  except Exception:
213
302
  pass
303
+ finally:
304
+ if lock:
305
+ _unlock(lock)
214
306
  return did
215
307
 
@@ -30,7 +30,7 @@ def test_webbee_upgrades_itself_by_its_own_tool():
30
30
  uv = au.self_command("0.6.0", prefix="/u/.local/share/uv/tools/webbee", env={}, uv="/bin/uv")
31
31
  assert uv == ["/bin/uv", "tool", "install", "--force", "--refresh", "webbee==0.6.0"]
32
32
  px = au.self_command("0.6.0", prefix="/h/.local/pipx/venvs/webbee", env={}, pipx="/bin/pipx")
33
- assert px == ["/bin/pipx", "install", "--force", "--pip-args=--no-cache-dir", "webbee==0.6.0"]
33
+ assert px == ["/bin/pipx", "runpip", "webbee", "install", "--upgrade", "--no-cache-dir", "webbee==0.6.0"]
34
34
  pip = au.self_command("0.6.0", prefix="/venv", executable="/venv/bin/python", env={})
35
35
  assert pip == ["/venv/bin/python", "-m", "pip", "install", "--upgrade", "--no-cache-dir", "webbee==0.6.0"]
36
36
  light = au.self_command("0.6.0", env={au.LIGHT_ENV: "/usr/bin/python3.8"}) # an old Python's light install
@@ -124,3 +124,53 @@ def test_a_release_is_installed_by_its_wheel_s_address_when_pypi_names_it():
124
124
  assert got[-1] == f"imperal-mcp[code] @ {mcp_url}"
125
125
  assert au.self_command("0.6.1", prefix="/v", executable="/v/bin/python", env={}, wheel=lambda d, v: None)[-1] \
126
126
  == "webbee==0.6.1"
127
+
128
+
129
+ # ── live 2026-10-25: two starts reinstalled one venv at once; the restart found it half-written ──
130
+ def test_pipx_upgrades_its_venv_in_place():
131
+ px = au.self_command("0.6.5", prefix="/h/.local/pipx/venvs/webbee", env={}, pipx="/bin/pipx")
132
+ assert px == ["/bin/pipx", "runpip", "webbee", "install", "--upgrade", "--no-cache-dir", "webbee==0.6.5"]
133
+ inst = {"tool": "pipx", "python": "/p/bin/python", "version": "0.8.2", "code": True}
134
+ assert au.mcp_command(inst, "0.8.3", None, "/bin/pipx") == [
135
+ "/bin/pipx", "runpip", "imperal-mcp", "install", "--upgrade", "--no-cache-dir", "imperal-mcp[code]==0.8.3"]
136
+ assert au.mcp_command({}, "0.8.3", None, "/bin/pipx")[:3] == ["/bin/pipx", "install", "--force"] # a new one
137
+
138
+
139
+ def test_a_second_start_waits_for_the_first_upgrade_and_starts_on_it(tmp_path):
140
+ lock = tmp_path / "webbee-upgrade.lock"
141
+ lock.write_text("1 0") # another webbee is upgrading right now
142
+ run, execd, env, said = _Run(), [], {}, []
143
+ slept = []
144
+
145
+ def sleep(s):
146
+ slept.append(s)
147
+ lock.unlink() # ... and it finishes
148
+ au.upgrade_before_start(["--once"], "0.6.4", say=said.append, fetch={"webbee": "0.6.5"}.get, wheel=None,
149
+ which=_which({"pipx": "/bin/pipx"}), run=run, execv=lambda p, a: execd.append(a),
150
+ env=env, lock_path=lambda: str(lock), sleep=sleep)
151
+ assert run.calls == [] and slept and execd and env[au.DONE_ENV] == "1" # no second install, the new release
152
+ assert "another webbee is updating" in said[0]
153
+
154
+
155
+ def test_the_lock_is_released_before_the_restart_and_a_broken_install_is_done_again(tmp_path, monkeypatch):
156
+ lock = tmp_path / "webbee-upgrade.lock"
157
+ seen = []
158
+ checks = iter([False, True]) # the first install left it broken, the second is whole
159
+ monkeypatch.setattr(au, "imports_ok", lambda python, run=None: next(checks))
160
+ run = _Run()
161
+ au.upgrade_before_start([], "0.6.4", say=lambda m: None, fetch={"webbee": "0.6.5"}.get, wheel=None,
162
+ which=_which({"pipx": "/bin/pipx"}), run=run, env={}, lock_path=lambda: str(lock),
163
+ execv=lambda p, a: seen.append(lock.exists()))
164
+ assert seen == [False] # no lock left behind by exec
165
+ assert len([c for c in run.calls if "install" in c]) == 2
166
+ assert not lock.exists()
167
+
168
+
169
+ def test_the_lock_lives_in_webbee_s_own_folder(tmp_path, monkeypatch):
170
+ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
171
+ monkeypatch.delenv("WEBBEE_RUNTIME_HOME", raising=False)
172
+ path = au._lock_path()
173
+ assert path.startswith(str(tmp_path / "data")) and path.endswith("upgrade.lock")
174
+ import os
175
+ import stat
176
+ assert stat.S_IMODE(os.stat(os.path.dirname(path)).st_mode) & 0o077 == 0 # only this user
@@ -1 +0,0 @@
1
- __version__ = "0.6.4"
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
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