webbee 0.3.7__tar.gz → 0.3.8__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 (82) hide show
  1. {webbee-0.3.7 → webbee-0.3.8}/CHANGELOG.md +10 -0
  2. {webbee-0.3.7 → webbee-0.3.8}/PKG-INFO +1 -1
  3. {webbee-0.3.7 → webbee-0.3.8}/pyproject.toml +1 -1
  4. webbee-0.3.8/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/repl.py +8 -4
  6. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/steer.py +7 -1
  7. webbee-0.3.8/src/webbee/tokens.py +39 -0
  8. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/tui.py +17 -7
  9. {webbee-0.3.7 → webbee-0.3.8}/tests/test_repl.py +8 -2
  10. webbee-0.3.8/tests/test_tokens.py +57 -0
  11. webbee-0.3.7/src/webbee/__init__.py +0 -1
  12. {webbee-0.3.7 → webbee-0.3.8}/.github/workflows/publish.yml +0 -0
  13. {webbee-0.3.7 → webbee-0.3.8}/.gitignore +0 -0
  14. {webbee-0.3.7 → webbee-0.3.8}/LICENSE +0 -0
  15. {webbee-0.3.7 → webbee-0.3.8}/README.md +0 -0
  16. {webbee-0.3.7 → webbee-0.3.8}/install.sh +0 -0
  17. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/account.py +0 -0
  18. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/banner_art.py +0 -0
  19. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/checkpoints.py +0 -0
  20. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/cli.py +0 -0
  21. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/clipboard.py +0 -0
  22. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/commands.py +0 -0
  23. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/config.py +0 -0
  24. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/details.py +0 -0
  25. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/events.py +0 -0
  26. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/frames.py +0 -0
  27. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/intel/__init__.py +0 -0
  28. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/intel/chunker.py +0 -0
  29. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/intel/embed.py +0 -0
  30. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/intel/graph.py +0 -0
  31. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/intel/indexer.py +0 -0
  32. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/intel/models.py +0 -0
  33. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/intel/query.py +0 -0
  34. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/intel/service.py +0 -0
  35. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/intel/store.py +0 -0
  36. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/intel/vectors.py +0 -0
  37. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/intel/watch.py +0 -0
  38. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/output_pane.py +0 -0
  39. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/remote.py +0 -0
  40. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/render.py +0 -0
  41. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/repo.py +0 -0
  42. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/session.py +0 -0
  43. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/sessions.py +0 -0
  44. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/stream.py +0 -0
  45. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/thread.py +0 -0
  46. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/tools.py +0 -0
  47. {webbee-0.3.7 → webbee-0.3.8}/src/webbee/update.py +0 -0
  48. {webbee-0.3.7 → webbee-0.3.8}/tests/__init__.py +0 -0
  49. {webbee-0.3.7 → webbee-0.3.8}/tests/test_account.py +0 -0
  50. {webbee-0.3.7 → webbee-0.3.8}/tests/test_checkpoints.py +0 -0
  51. {webbee-0.3.7 → webbee-0.3.8}/tests/test_cli.py +0 -0
  52. {webbee-0.3.7 → webbee-0.3.8}/tests/test_clipboard.py +0 -0
  53. {webbee-0.3.7 → webbee-0.3.8}/tests/test_commands.py +0 -0
  54. {webbee-0.3.7 → webbee-0.3.8}/tests/test_config.py +0 -0
  55. {webbee-0.3.7 → webbee-0.3.8}/tests/test_cpc_contract_stable.py +0 -0
  56. {webbee-0.3.7 → webbee-0.3.8}/tests/test_details.py +0 -0
  57. {webbee-0.3.7 → webbee-0.3.8}/tests/test_events.py +0 -0
  58. {webbee-0.3.7 → webbee-0.3.8}/tests/test_freeze_fix.py +0 -0
  59. {webbee-0.3.7 → webbee-0.3.8}/tests/test_intel_chunker.py +0 -0
  60. {webbee-0.3.7 → webbee-0.3.8}/tests/test_intel_embed.py +0 -0
  61. {webbee-0.3.7 → webbee-0.3.8}/tests/test_intel_graph.py +0 -0
  62. {webbee-0.3.7 → webbee-0.3.8}/tests/test_intel_indexer.py +0 -0
  63. {webbee-0.3.7 → webbee-0.3.8}/tests/test_intel_no_numpy.py +0 -0
  64. {webbee-0.3.7 → webbee-0.3.8}/tests/test_intel_query.py +0 -0
  65. {webbee-0.3.7 → webbee-0.3.8}/tests/test_intel_service.py +0 -0
  66. {webbee-0.3.7 → webbee-0.3.8}/tests/test_intel_store.py +0 -0
  67. {webbee-0.3.7 → webbee-0.3.8}/tests/test_intel_vectors.py +0 -0
  68. {webbee-0.3.7 → webbee-0.3.8}/tests/test_intel_watch.py +0 -0
  69. {webbee-0.3.7 → webbee-0.3.8}/tests/test_marathon.py +0 -0
  70. {webbee-0.3.7 → webbee-0.3.8}/tests/test_packaging.py +0 -0
  71. {webbee-0.3.7 → webbee-0.3.8}/tests/test_render.py +0 -0
  72. {webbee-0.3.7 → webbee-0.3.8}/tests/test_repo.py +0 -0
  73. {webbee-0.3.7 → webbee-0.3.8}/tests/test_session.py +0 -0
  74. {webbee-0.3.7 → webbee-0.3.8}/tests/test_sessions.py +0 -0
  75. {webbee-0.3.7 → webbee-0.3.8}/tests/test_steer.py +0 -0
  76. {webbee-0.3.7 → webbee-0.3.8}/tests/test_stream.py +0 -0
  77. {webbee-0.3.7 → webbee-0.3.8}/tests/test_thread.py +0 -0
  78. {webbee-0.3.7 → webbee-0.3.8}/tests/test_tools.py +0 -0
  79. {webbee-0.3.7 → webbee-0.3.8}/tests/test_tui.py +0 -0
  80. {webbee-0.3.7 → webbee-0.3.8}/tests/test_tui_hardening.py +0 -0
  81. {webbee-0.3.7 → webbee-0.3.8}/tests/test_update.py +0 -0
  82. {webbee-0.3.7 → webbee-0.3.8}/tests/test_version.py +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.8
4
+
5
+ - No more sign-in races: token refresh is serialized and retries once after
6
+ a sibling terminal rotates the session — multiple open terminals no longer
7
+ knock each other out with "session expired".
8
+ - No more dock lockup: a turn that fails (for example, an expired session)
9
+ now clears the "working" state, and every key keeps responding even if the
10
+ turn state ever goes stale. The idle pickup poller also backs off instead
11
+ of hammering a signed-out session.
12
+
3
13
  ## 0.3.7
4
14
 
5
15
  - Terminal liveness: a consent answered from Telegram/panel no longer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.7
3
+ Version: 0.3.8
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.3.7"
3
+ version = "0.3.8"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.8"
@@ -58,8 +58,8 @@ async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
58
58
 
59
59
  workspace = os.getcwd()
60
60
 
61
- async def token_provider() -> str:
62
- return await auth.ensure_access_token(cfg)
61
+ from webbee.tokens import make_token_provider
62
+ token_provider = make_token_provider(cfg, auth)
63
63
 
64
64
  if agent_factory is None:
65
65
  agent_factory = lambda c, tp, ws, m: AgentSession(c, tp, ws, m) # noqa: E731
@@ -72,9 +72,11 @@ async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
72
72
  except (KeyboardInterrupt, asyncio.CancelledError):
73
73
  await agent.stop()
74
74
  sink.note("Interrupted.")
75
+ sink.end_turn("") # clear busy (poller starvation guard)
75
76
  return ""
76
77
  except Exception as e: # network/auth/etc — never crash
77
78
  sink.note(f"Error: {type(e).__name__}: {e}")
79
+ sink.end_turn("") # clear busy: a stuck 'working' also starves the idle-steer poller
78
80
  return ""
79
81
  sink.end_turn(text)
80
82
  return text
@@ -122,8 +124,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
122
124
 
123
125
  workspace = os.getcwd()
124
126
 
125
- async def token_provider() -> str:
126
- return await auth.ensure_access_token(cfg)
127
+ from webbee.tokens import make_token_provider
128
+ token_provider = make_token_provider(cfg, auth)
127
129
 
128
130
  # Prod dock path = the default reader + a real tty + no injected sink; tests
129
131
  # inject sink/read_line and take the plain fallback loop.
@@ -287,9 +289,11 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
287
289
  except (KeyboardInterrupt, asyncio.CancelledError):
288
290
  _sink.abort()
289
291
  _sink.note("Interrupted.")
292
+ _sink.end_turn("") # clear busy (poller starvation guard)
290
293
  return
291
294
  except Exception as e: # network/auth/etc — never crash the REPL
292
295
  _sink.note(f"Error: {type(e).__name__}: {e}")
296
+ _sink.end_turn("") # clear busy: a stuck 'working' also starves the idle-steer poller
293
297
  return
294
298
  _sink.end_turn(text)
295
299
 
@@ -73,8 +73,10 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
73
73
  from webbee.thread import fetch_pending_steer
74
74
  derived = ""
75
75
  backlog: deque = deque()
76
+ failures = 0 # consecutive fetch/auth failures -> backoff (a logged-out
77
+ # terminal must not hammer the token-refresh path every 4s)
76
78
  while True:
77
- await asyncio.sleep(interval)
79
+ await asyncio.sleep(min(interval * (2 ** min(failures, 4)), 60.0))
78
80
  try:
79
81
  if is_busy():
80
82
  continue
@@ -103,4 +105,8 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
103
105
  except Exception:
104
106
  # Fail-soft by design: a network blip / auth hiccup skips this
105
107
  # tick; undrained items stay durable on the gateway (1h TTL).
108
+ # Consecutive failures back the poll off (up to 60s) so a
109
+ # logged-out terminal never hammers the token-refresh path.
110
+ failures += 1
106
111
  continue
112
+ failures = 0
@@ -0,0 +1,39 @@
1
+ """Serialized token acquisition for every webbee gateway call.
2
+
3
+ The gateway rotates refresh tokens SINGLE-USE (an atomic claim revokes the
4
+ presented token and mints a new pair) and the SDK's ensure_access_token has
5
+ no lock: two concurrent refreshes at an access-token expiry boundary — the
6
+ idle-steer poller vs. a starting turn, or two open terminals sharing the
7
+ same on-disk creds — race, and the loser's refresh 401s as "session
8
+ expired" (Valentin, live 2026-07-15, right after 0.3.7 made the poller a
9
+ frequent caller).
10
+
11
+ Two-layer guard:
12
+ * an asyncio.Lock serializes ALL in-process acquisitions (poller / boot
13
+ replay / turns never refresh concurrently);
14
+ * on failure, ONE short-delay retry — ensure_access_token re-reads the
15
+ creds file, so when a SIBLING terminal won the race and saved the
16
+ rotated pair, the retry succeeds from its fresh refresh token. A real
17
+ logged-out state still fails (both attempts), just ~0.6s later.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+
23
+ _RETRY_DELAY_S = 0.6
24
+
25
+ _lock = asyncio.Lock()
26
+
27
+
28
+ def make_token_provider(cfg, auth):
29
+ """The ONE token_provider factory the REPL/marathon paths share."""
30
+
31
+ async def token_provider() -> str:
32
+ async with _lock:
33
+ try:
34
+ return await auth.ensure_access_token(cfg)
35
+ except Exception:
36
+ await asyncio.sleep(_RETRY_DELAY_S)
37
+ return await auth.ensure_access_token(cfg)
38
+
39
+ return token_provider
@@ -157,6 +157,16 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
157
157
 
158
158
  kb = KeyBindings()
159
159
 
160
+ def _busy_live() -> bool:
161
+ """Lockout-proof busy for the key handlers: busy only while the turn
162
+ TASK is genuinely alive. A turn that died without clearing the sink's
163
+ busy flag (an error path that skipped end_turn) must never brick the
164
+ dock -- Enter/Esc/Ctrl-C/Ctrl-D all gate on THIS, so a stale flag
165
+ degrades to a cosmetic toolbar glitch instead of an unusable input
166
+ (Valentin, live 2026-07-15: 'working' spun and NO key reacted)."""
167
+ t = turn.get("task")
168
+ return bool(is_busy() and t is not None and not t.done())
169
+
160
170
  @kb.add("enter")
161
171
  def _enter(event):
162
172
  text = scrub_mouse_residue(buf.text) # never send leaked mouse reports
@@ -164,11 +174,11 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
164
174
  if consent_pending():
165
175
  resolve_consent(text) # ICNLI: relay the raw reply verbatim
166
176
  return
167
- if not text.strip() and sel["i"] is not None and steps_nav and not is_busy():
177
+ if not text.strip() and sel["i"] is not None and steps_nav and not _busy_live():
168
178
  idx, sel["i"] = sel["i"], None
169
179
  event.app.create_background_task(steps_nav["expand"](idx))
170
180
  return
171
- if is_busy() or not text.strip():
181
+ if _busy_live() or not text.strip():
172
182
  return
173
183
  turn["task"] = event.app.create_background_task(_run_turn(text))
174
184
 
@@ -179,11 +189,11 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
179
189
 
180
190
  @kb.add("c-c")
181
191
  def _interrupt(event):
182
- _interrupt_action(turn, is_busy, stop_turn, event)
192
+ _interrupt_action(turn, _busy_live, stop_turn, event)
183
193
 
184
194
  @kb.add("c-d")
185
195
  def _eof(event):
186
- if not is_busy():
196
+ if not _busy_live():
187
197
  event.app.exit()
188
198
 
189
199
  sel = {"i": None} # None = no selection; else 0-based step index
@@ -197,20 +207,20 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
197
207
  @kb.add("up")
198
208
  def _step_up(event):
199
209
  n = _nav_count()
200
- if n and not buf.text and not is_busy():
210
+ if n and not buf.text and not _busy_live():
201
211
  sel["i"] = (n - 1) if sel["i"] is None else max(0, sel["i"] - 1)
202
212
  event.app.invalidate()
203
213
 
204
214
  @kb.add("down")
205
215
  def _step_down(event):
206
216
  n = _nav_count()
207
- if n and not buf.text and not is_busy():
217
+ if n and not buf.text and not _busy_live():
208
218
  sel["i"] = 0 if sel["i"] is None else min(n - 1, sel["i"] + 1)
209
219
  event.app.invalidate()
210
220
 
211
221
  @kb.add("escape")
212
222
  def _step_clear(event):
213
- _escape_action(sel, turn, is_busy, stop_turn, event, buf=buf)
223
+ _escape_action(sel, turn, _busy_live, stop_turn, event, buf=buf)
214
224
 
215
225
  @kb.add("pageup")
216
226
  def _pgup(event):
@@ -162,7 +162,10 @@ def test_agent_error_is_swallowed_and_loop_continues():
162
162
  assert agent.tasks == ["do it"]
163
163
  assert any("Error" in n for n in sink.notes)
164
164
  assert not any(NO_CYRILLIC.search(n) for n in sink.notes)
165
- assert sink.turns == []
165
+ # Liveness: this path MUST clear busy via end_turn("") -- a stuck busy
166
+ # flag locked the whole dock out (live 2026-07-15) and starves the idle-
167
+ # steer poller. Empty text = no final panel, just the state reset.
168
+ assert sink.turns == [""]
166
169
 
167
170
 
168
171
  def test_login_command_calls_auth_and_logs_in():
@@ -234,7 +237,10 @@ def test_ctrl_c_mid_turn_aborts_and_returns_to_prompt():
234
237
  assert sink.aborted is True
235
238
  assert any("Interrupted" in n for n in sink.notes)
236
239
  assert not any(NO_CYRILLIC.search(n) for n in sink.notes)
237
- assert sink.turns == []
240
+ # Liveness: this path MUST clear busy via end_turn("") -- a stuck busy
241
+ # flag locked the whole dock out (live 2026-07-15) and starves the idle-
242
+ # steer poller. Empty text = no final panel, just the state reset.
243
+ assert sink.turns == [""]
238
244
 
239
245
 
240
246
  def test_welcome_shown_on_start():
@@ -0,0 +1,57 @@
1
+ """Serialized token provider (webbee.tokens) — the anti-lockout auth guard."""
2
+ import asyncio
3
+
4
+ import pytest
5
+
6
+ from webbee.tokens import make_token_provider
7
+
8
+
9
+ class _Auth:
10
+ def __init__(self, fail_first: int = 0):
11
+ self.calls = 0
12
+ self.concurrent = 0
13
+ self.max_concurrent = 0
14
+ self.fail_first = fail_first
15
+
16
+ async def ensure_access_token(self, cfg):
17
+ self.calls += 1
18
+ self.concurrent += 1
19
+ self.max_concurrent = max(self.max_concurrent, self.concurrent)
20
+ try:
21
+ await asyncio.sleep(0.01)
22
+ if self.calls <= self.fail_first:
23
+ raise RuntimeError("invalid refresh token")
24
+ return f"tok{self.calls}"
25
+ finally:
26
+ self.concurrent -= 1
27
+
28
+
29
+ def test_concurrent_acquisitions_serialized():
30
+ auth = _Auth()
31
+ tp = make_token_provider(object(), auth)
32
+
33
+ async def go():
34
+ return await asyncio.gather(tp(), tp(), tp())
35
+
36
+ toks = asyncio.run(go())
37
+ assert all(t.startswith("tok") for t in toks)
38
+ assert auth.max_concurrent == 1 # the lock: never two refreshes at once
39
+
40
+
41
+ def test_failure_retries_once_after_sibling_rotation(monkeypatch):
42
+ import webbee.tokens as wt
43
+ monkeypatch.setattr(wt, "_RETRY_DELAY_S", 0)
44
+ auth = _Auth(fail_first=1) # first attempt loses the rotation race
45
+ tp = make_token_provider(object(), auth)
46
+ assert asyncio.run(tp()) == "tok2" # retry re-reads (sibling saved) and wins
47
+ assert auth.calls == 2
48
+
49
+
50
+ def test_true_logged_out_still_fails(monkeypatch):
51
+ import webbee.tokens as wt
52
+ monkeypatch.setattr(wt, "_RETRY_DELAY_S", 0)
53
+ auth = _Auth(fail_first=99)
54
+ tp = make_token_provider(object(), auth)
55
+ with pytest.raises(RuntimeError):
56
+ asyncio.run(tp())
57
+ assert auth.calls == 2 # exactly one retry, no hammering
@@ -1 +0,0 @@
1
- __version__ = "0.3.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