webbee 0.3.8__tar.gz → 0.3.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 (85) hide show
  1. {webbee-0.3.8 → webbee-0.3.9}/CHANGELOG.md +6 -0
  2. {webbee-0.3.8 → webbee-0.3.9}/PKG-INFO +1 -1
  3. {webbee-0.3.8 → webbee-0.3.9}/pyproject.toml +1 -1
  4. webbee-0.3.9/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/account.py +15 -0
  6. webbee-0.3.9/src/webbee/boot.py +108 -0
  7. webbee-0.3.9/src/webbee/coding_context.py +83 -0
  8. webbee-0.3.9/src/webbee/consent.py +134 -0
  9. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/frames.py +32 -0
  10. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/repl.py +11 -103
  11. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/session.py +7 -237
  12. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/steer.py +1 -1
  13. {webbee-0.3.8 → webbee-0.3.9}/tests/test_freeze_fix.py +2 -1
  14. {webbee-0.3.8 → webbee-0.3.9}/tests/test_marathon.py +1 -1
  15. {webbee-0.3.8 → webbee-0.3.9}/tests/test_session.py +4 -2
  16. {webbee-0.3.8 → webbee-0.3.9}/tests/test_tui_hardening.py +1 -1
  17. webbee-0.3.8/src/webbee/__init__.py +0 -1
  18. {webbee-0.3.8 → webbee-0.3.9}/.github/workflows/publish.yml +0 -0
  19. {webbee-0.3.8 → webbee-0.3.9}/.gitignore +0 -0
  20. {webbee-0.3.8 → webbee-0.3.9}/LICENSE +0 -0
  21. {webbee-0.3.8 → webbee-0.3.9}/README.md +0 -0
  22. {webbee-0.3.8 → webbee-0.3.9}/install.sh +0 -0
  23. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/banner_art.py +0 -0
  24. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/checkpoints.py +0 -0
  25. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/cli.py +0 -0
  26. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/clipboard.py +0 -0
  27. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/commands.py +0 -0
  28. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/config.py +0 -0
  29. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/details.py +0 -0
  30. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/events.py +0 -0
  31. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/__init__.py +0 -0
  32. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/chunker.py +0 -0
  33. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/embed.py +0 -0
  34. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/graph.py +0 -0
  35. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/indexer.py +0 -0
  36. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/models.py +0 -0
  37. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/query.py +0 -0
  38. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/service.py +0 -0
  39. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/store.py +0 -0
  40. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/vectors.py +0 -0
  41. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/watch.py +0 -0
  42. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/output_pane.py +0 -0
  43. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/remote.py +0 -0
  44. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/render.py +0 -0
  45. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/repo.py +0 -0
  46. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/sessions.py +0 -0
  47. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/stream.py +0 -0
  48. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/thread.py +0 -0
  49. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/tokens.py +0 -0
  50. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/tools.py +0 -0
  51. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/tui.py +0 -0
  52. {webbee-0.3.8 → webbee-0.3.9}/src/webbee/update.py +0 -0
  53. {webbee-0.3.8 → webbee-0.3.9}/tests/__init__.py +0 -0
  54. {webbee-0.3.8 → webbee-0.3.9}/tests/test_account.py +0 -0
  55. {webbee-0.3.8 → webbee-0.3.9}/tests/test_checkpoints.py +0 -0
  56. {webbee-0.3.8 → webbee-0.3.9}/tests/test_cli.py +0 -0
  57. {webbee-0.3.8 → webbee-0.3.9}/tests/test_clipboard.py +0 -0
  58. {webbee-0.3.8 → webbee-0.3.9}/tests/test_commands.py +0 -0
  59. {webbee-0.3.8 → webbee-0.3.9}/tests/test_config.py +0 -0
  60. {webbee-0.3.8 → webbee-0.3.9}/tests/test_cpc_contract_stable.py +0 -0
  61. {webbee-0.3.8 → webbee-0.3.9}/tests/test_details.py +0 -0
  62. {webbee-0.3.8 → webbee-0.3.9}/tests/test_events.py +0 -0
  63. {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_chunker.py +0 -0
  64. {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_embed.py +0 -0
  65. {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_graph.py +0 -0
  66. {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_indexer.py +0 -0
  67. {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_no_numpy.py +0 -0
  68. {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_query.py +0 -0
  69. {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_service.py +0 -0
  70. {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_store.py +0 -0
  71. {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_vectors.py +0 -0
  72. {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_watch.py +0 -0
  73. {webbee-0.3.8 → webbee-0.3.9}/tests/test_packaging.py +0 -0
  74. {webbee-0.3.8 → webbee-0.3.9}/tests/test_render.py +0 -0
  75. {webbee-0.3.8 → webbee-0.3.9}/tests/test_repl.py +0 -0
  76. {webbee-0.3.8 → webbee-0.3.9}/tests/test_repo.py +0 -0
  77. {webbee-0.3.8 → webbee-0.3.9}/tests/test_sessions.py +0 -0
  78. {webbee-0.3.8 → webbee-0.3.9}/tests/test_steer.py +0 -0
  79. {webbee-0.3.8 → webbee-0.3.9}/tests/test_stream.py +0 -0
  80. {webbee-0.3.8 → webbee-0.3.9}/tests/test_thread.py +0 -0
  81. {webbee-0.3.8 → webbee-0.3.9}/tests/test_tokens.py +0 -0
  82. {webbee-0.3.8 → webbee-0.3.9}/tests/test_tools.py +0 -0
  83. {webbee-0.3.8 → webbee-0.3.9}/tests/test_tui.py +0 -0
  84. {webbee-0.3.8 → webbee-0.3.9}/tests/test_update.py +0 -0
  85. {webbee-0.3.8 → webbee-0.3.9}/tests/test_version.py +0 -0
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.9
4
+
5
+ - Internal restructure: the session engine was split into focused modules
6
+ (consent handling, coding context, boot) — no behavior change.
7
+
8
+
3
9
  ## 0.3.8
4
10
 
5
11
  - No more sign-in races: token refresh is serialized and retries once after
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.8
3
+ Version: 0.3.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.3.8"
3
+ version = "0.3.9"
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.9"
@@ -39,6 +39,21 @@ async def _default_get(cfg, token: str, path: str) -> dict:
39
39
  return r.json()
40
40
 
41
41
 
42
+ async def login_device_flow(cfg, auth, sink) -> str:
43
+ """ONE shared imperal_mcp mechanism: device-code flow (RFC 8628), async,
44
+ so the caller awaits it directly on the dock's event loop (the /login turn
45
+ runs as a background task, so the dock stays responsive while it polls).
46
+ on_prompt renders the code + URL into the feed — a bare print would be
47
+ invisible in the dock. Returns the signed-in email."""
48
+ def _login_prompt(user_code, uri, uri_complete):
49
+ show = getattr(sink, "login_prompt", None)
50
+ if show:
51
+ show(user_code, uri)
52
+ else:
53
+ sink.note(f"Open {uri} and enter code: {user_code}")
54
+ return await auth.login_device(cfg, on_prompt=_login_prompt)
55
+
56
+
42
57
  async def fetch_account(cfg, token_provider, *, get=None) -> Account:
43
58
  """Best-effort account summary for the welcome screen. NEVER raises: no
44
59
  token or a failed /v1/auth/me -> Account(signed_in=False); a failed
@@ -0,0 +1,108 @@
1
+ """REPL boot helpers (extracted verbatim from webbee.repl): everything the
2
+ dock sets up before the first prompt — the cached git branch, the dock's
3
+ stderr log file, the intel/shadow default factories and their guarded
4
+ starters, and the best-effort boot replay of the durable coding thread.
5
+ All fail-soft: boot must never crash over a nice-to-have."""
6
+ import asyncio
7
+ import os
8
+ import subprocess
9
+
10
+
11
+ def _git_branch(workspace: str) -> str:
12
+ try:
13
+ p = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=workspace,
14
+ capture_output=True, text=True, timeout=5)
15
+ return p.stdout.strip() if p.returncode == 0 else "-"
16
+ except (OSError, subprocess.SubprocessError):
17
+ return "-"
18
+
19
+
20
+ def _open_dock_stderr_log():
21
+ """A file to swallow stderr for the full-screen dock's lifetime. The dock is
22
+ a prompt_toolkit full-screen Application that OWNS the terminal (it diffs the
23
+ screen); ANY stray write to stderr while it runs — a dependency's tqdm
24
+ download bar, a library warning, a background watcher-task traceback —
25
+ desyncs that diff and shows up as overlapping/duplicated text. Routing
26
+ stderr to ~/.cache/webbee/tui-stderr.log keeps the dock pixel-clean while
27
+ still preserving errors for debugging. Falls back to os.devnull if the cache
28
+ dir is unwritable; NEVER raises."""
29
+ try:
30
+ d = os.path.expanduser("~/.cache/webbee")
31
+ os.makedirs(d, exist_ok=True)
32
+ return open(os.path.join(d, "tui-stderr.log"), "a", buffering=1, encoding="utf-8")
33
+ except Exception:
34
+ try:
35
+ return open(os.devnull, "w")
36
+ except Exception:
37
+ import io
38
+ return io.StringIO()
39
+
40
+
41
+ def _default_shadow_factory(cfg, workspace: str):
42
+ """The reversibility shadow git. Guarded like intel: any failure (no git
43
+ binary, cache not writable) degrades to None -- coding still works, just
44
+ without the time machine."""
45
+ from webbee.checkpoints import ShadowGit, shadow_key
46
+ from webbee.repo import find_repo_root
47
+ root = find_repo_root(workspace)
48
+ sg = ShadowGit(root, shadow_key(root), cache_dir=cfg.cache_dir)
49
+ return sg if sg.ensure() else None
50
+
51
+
52
+ def _default_intel_factory(cfg, workspace: str):
53
+ """Lazy/guarded -- a base install (no tree-sitter/watchfiles extra) must
54
+ never fail to import here; the intel boot wraps the whole build in
55
+ try/except so any error (missing extra, indexing failure) degrades to
56
+ `intel=None` rather than crashing the REPL."""
57
+ from webbee.intel.service import IntelService
58
+ from webbee.repo import compute_repo_key, find_repo_root
59
+ root = find_repo_root(workspace)
60
+ return IntelService(root, compute_repo_key(root), cache_dir=cfg.cache_dir)
61
+
62
+
63
+ async def replay_thread(cfg, token_provider, sink) -> None:
64
+ """Boot replay of the durable per-user thread (Task 9): best-effort,
65
+ entirely swallowed on any failure -- history is a nice-to-have,
66
+ never a boot blocker (network down, no such session yet, etc.)."""
67
+ try:
68
+ from imperal_mcp.client import ImperalClient
69
+ from webbee.thread import (conversational_text, fetch_recent_thread,
70
+ truncate_for_display)
71
+ _iid = await ImperalClient(cfg, token_provider).whoami()
72
+ _msgs = await fetch_recent_thread(cfg, token_provider, f"marathon-{_iid}-rboot")
73
+ _shown = 0
74
+ for _m in _msgs[-40:]:
75
+ _text = conversational_text(_m.get("content", ""))
76
+ if not _text:
77
+ continue # pure tool traffic -- mind-food, not conversation
78
+ sink.foreign_turn(_m.get("surface", "terminal"), _m.get("role", ""),
79
+ truncate_for_display(_text))
80
+ _shown += 1
81
+ if _shown:
82
+ sink.note("— live —")
83
+ except Exception:
84
+ pass # replay is best-effort; never block boot
85
+
86
+
87
+ async def start_intel(cfg, workspace: str, intel_factory):
88
+ """Off-loop intel build (indexing does sync file I/O + subprocess). Any
89
+ failure here (missing extra, bad repo, etc.) must degrade to
90
+ (None, None), never crash the boot -- coding still works, just
91
+ without repo intelligence. Returns (intel, watcher_task)."""
92
+ try:
93
+ svc = (intel_factory or _default_intel_factory)(cfg, workspace)
94
+ await asyncio.to_thread(svc.build)
95
+ from webbee.intel import watch
96
+ watcher_task = asyncio.ensure_future(watch.watch_workspace(svc.root, svc.apply_changes))
97
+ return svc, watcher_task
98
+ except Exception:
99
+ return None, None
100
+
101
+
102
+ async def start_shadow(cfg, workspace: str, shadow_factory):
103
+ """Whole-mind P4: the reversibility shadow (never the user's VCS);
104
+ guarded -- boot must not fail over the time machine."""
105
+ try:
106
+ return await asyncio.to_thread(shadow_factory or _default_shadow_factory, cfg, workspace)
107
+ except Exception:
108
+ return None
@@ -0,0 +1,83 @@
1
+ """Coding-context snapshot for the cloud brain (extracted verbatim from
2
+ webbee.session): build_coding_context packages cwd/git/tree/repo identity
3
+ (+ optional repo_profile), and detect_verify_cmd is the CLIENT-detected
4
+ proof-of-done command a marathon carries in that context."""
5
+ import os
6
+ import subprocess
7
+
8
+
9
+ def build_coding_context(workspace_root: str, intel=None) -> dict:
10
+ """Snapshot handed to the cloud brain: cwd (realpath), `git status -sb`
11
+ (empty for non-git/any error), a bounded newline-joined file tree, and —
12
+ when a ready intel service is injected — the precomputed repo_profile.
13
+ The profile is READ from the already-built index (cheap); indexing never
14
+ happens inline here."""
15
+ cwd = os.path.realpath(workspace_root)
16
+ try:
17
+ proc = subprocess.run(
18
+ ["git", "status", "-sb"], cwd=cwd,
19
+ capture_output=True, text=True, timeout=10,
20
+ )
21
+ git = proc.stdout if proc.returncode == 0 else ""
22
+ except (OSError, subprocess.SubprocessError):
23
+ git = ""
24
+ paths = []
25
+ for dirpath, dirnames, filenames in os.walk(cwd):
26
+ dirnames[:] = [d for d in dirnames if d != ".git" and not d.startswith(".")]
27
+ for fn in filenames:
28
+ paths.append(os.path.relpath(os.path.join(dirpath, fn), cwd))
29
+ if len(paths) >= 200:
30
+ break
31
+ if len(paths) >= 200:
32
+ break
33
+ from webbee.repo import compute_repo_key, find_repo_root
34
+ root = find_repo_root(cwd)
35
+ d = {"cwd": cwd, "git": git, "tree": "\n".join(paths),
36
+ "repo_key": compute_repo_key(root), "repo_root": root}
37
+ if intel is not None and getattr(intel, "ready", False):
38
+ try:
39
+ d["repo_profile"] = intel.repo_profile()
40
+ except Exception:
41
+ pass
42
+ return d
43
+
44
+
45
+ def detect_verify_cmd(repo_root: str) -> str:
46
+ """Best-effort project test command, CLIENT-detected from the repo layout.
47
+
48
+ SECURITY-load-bearing: in a marathon the kernel runs ONLY this command as
49
+ proof-of-done — the cloud brain can never author a shell command. So this
50
+ is deliberately small + honest: a fixed command per recognised ecosystem,
51
+ NO guessing beyond these. An empty string means "no known runner" → the
52
+ kernel falls back to an LLM-judged done-check. Checked in a stable order."""
53
+ import json as _json
54
+
55
+ root = repo_root or "."
56
+
57
+ def _has(name: str) -> bool:
58
+ return os.path.isfile(os.path.join(root, name))
59
+
60
+ if _has("pyproject.toml") or _has("setup.cfg") or _has("tox.ini"):
61
+ return "pytest -q"
62
+ if _has("package.json"):
63
+ try:
64
+ with open(os.path.join(root, "package.json"), encoding="utf-8") as f:
65
+ pkg = _json.load(f)
66
+ scripts = pkg.get("scripts") if isinstance(pkg, dict) else None
67
+ if isinstance(scripts, dict) and scripts.get("test"):
68
+ return "npm test"
69
+ except (OSError, ValueError):
70
+ pass
71
+ if _has("Cargo.toml"):
72
+ return "cargo test"
73
+ if _has("go.mod"):
74
+ return "go test ./..."
75
+ if _has("Makefile"):
76
+ try:
77
+ import re as _re
78
+ with open(os.path.join(root, "Makefile"), encoding="utf-8") as f:
79
+ if _re.search(r"(?m)^test:", f.read()):
80
+ return "make test"
81
+ except OSError:
82
+ pass
83
+ return ""
@@ -0,0 +1,134 @@
1
+ """Consent path + the liveness-A consent race (extracted verbatim from
2
+ webbee.session): handle_confirm_request relays the user's RAW reply for the
3
+ kernel brain to interpret (ICNLI, safe-by-default), and race_consent lets a
4
+ pending local prompt RACE the stream so a consent answered from ANOTHER
5
+ surface (Telegram relay) unfreezes the terminal instead of leaving the dock
6
+ stuck on `approve? y/n`."""
7
+ import asyncio
8
+ from dataclasses import dataclass
9
+
10
+ from webbee.frames import _is_foreign_frame, render_foreign_frame
11
+
12
+
13
+ async def handle_confirm_request(frame: dict, mode: str, ask_consent) -> dict:
14
+ """ICNLI consent path. The client does NOT interpret consent words — it
15
+ relays the user's RAW reply; the kernel brain interprets intent
16
+ (safe-by-default). autopilot/plan are explicit and never prompt.
17
+ `ask_consent(app_id, tool, args)` is an async coroutine returning the raw
18
+ reply (resolved through the pinned dock, or a sync fallback reader)."""
19
+ req_id = frame["req_id"]
20
+ if mode == "autopilot":
21
+ return {"req_id": req_id, "result": {"approved": True}}
22
+ if mode == "plan":
23
+ return {"req_id": req_id, "result": {"approved": False, "reason": "plan_mode"}}
24
+ try:
25
+ raw = await ask_consent(frame.get("app_id", ""), frame.get("tool", ""), frame.get("args", {}))
26
+ except Exception:
27
+ # Consent UI failed -> decline (safe-by-default). NEVER leave the kernel
28
+ # hanging on an unanswered confirm — an unposted result froze the dock.
29
+ return {"req_id": req_id, "result": {"approved": False, "reason": "consent_error"}}
30
+ return {"req_id": req_id, "result": {"consent_reply": raw}}
31
+
32
+
33
+ async def _retire(task) -> None:
34
+ """Cancel + drain a racing task so it never outlives the frame loop
35
+ (liveness A hygiene). Absorbs the loser's outcome — CancelledError, a
36
+ late result, StopAsyncIteration, a transport error — retirement must
37
+ never introduce a new failure path. An externally-delivered cancellation
38
+ of the CALLER (task finished before our cancel landed) still propagates."""
39
+ task.cancel()
40
+ try:
41
+ await task
42
+ except asyncio.CancelledError:
43
+ if not task.cancelled():
44
+ raise # ours was the outer cancel — propagate
45
+ except Exception:
46
+ pass
47
+
48
+
49
+ def _dismiss_consent(sink) -> None:
50
+ """Tell the UI the pending consent prompt was answered on ANOTHER surface
51
+ (liveness A). Guarded like the other optional sink hooks — a minimal sink
52
+ without `consent_dismissed` (or a UI bug) must never break the frame loop."""
53
+ fn = getattr(sink, "consent_dismissed", None)
54
+ if fn is None:
55
+ return
56
+ try:
57
+ fn("↩ answered from another surface")
58
+ except Exception:
59
+ pass
60
+
61
+
62
+ @dataclass
63
+ class _ConsentRace:
64
+ """Outcome of racing the local consent prompt against the stream
65
+ (liveness A). Exactly one shape per race:
66
+ * out — the prompt resolved locally (today's path): POST it; a
67
+ pulled-ahead __anext__ may ride along in carry_task.
68
+ * carry_frame — the stream won: the park ended elsewhere; the prompt was
69
+ cancelled + dismissed, nothing is posted, and the pulled
70
+ frame is handed back to the loop for normal handling.
71
+ * stream_ended — StopAsyncIteration mid-consent: exit as stream end.
72
+ """
73
+ out: dict | None = None
74
+ carry_task: asyncio.Task | None = None
75
+ carry_frame: dict | None = None
76
+ stream_ended: bool = False
77
+
78
+
79
+ async def race_consent(frame: dict, sink, stream, *, mode: str, task_id: str) -> _ConsentRace:
80
+ """LIVE BUG fix (Valentin, 2026-07-15): a consent answered from
81
+ Telegram resolves the kernel park, but the frame loop used to block
82
+ INLINE in handle_confirm_request — the dock froze on `approve? y/n`
83
+ and the rest of the turn never rendered until a local keypress. The
84
+ local prompt now RACES the stream:
85
+ * the prompt resolves first -> POST it (today's path, unchanged);
86
+ the pulled-ahead frame rides back to the loop in carry_task.
87
+ * a re-published confirm_request with the SAME req_id (kernel
88
+ I-MARATHON-USER-WAKE presence re-publish) -> NOT an answer; the
89
+ prompt stays up and both racers keep going.
90
+ * any other OWN-turn frame -> the park ended elsewhere (answered
91
+ from another surface / superseded): cancel the prompt, dismiss the
92
+ dock, POST NOTHING (the kernel accepts only the FIRST result per
93
+ issued req_id), hand the frame back for normal handling.
94
+ * foreign frames (C7) stay display-only and never end the park.
95
+ Consent is never auto-approved here — autopilot/plan resolve instantly
96
+ inside handle_confirm_request exactly as before."""
97
+ rid = frame.get("req_id")
98
+ consent_task = asyncio.ensure_future(
99
+ handle_confirm_request(frame, mode, sink.ask_consent))
100
+ pull = None
101
+ try:
102
+ while True:
103
+ if pull is None:
104
+ pull = asyncio.ensure_future(stream.__anext__())
105
+ await asyncio.wait({consent_task, pull},
106
+ return_when=asyncio.FIRST_COMPLETED)
107
+ if consent_task.done():
108
+ # The local answer wins — even when both racers finished in
109
+ # the same cycle the pulled frame is NOT lost: the loop owns
110
+ # carry_task and processes it next.
111
+ return _ConsentRace(out=consent_task.result(), carry_task=pull)
112
+ nxt_pull, pull = pull, None
113
+ try:
114
+ nxt = nxt_pull.result()
115
+ except StopAsyncIteration:
116
+ # Stream ended mid-consent: retire the prompt safely and
117
+ # exit exactly as today's stream end (no result posted).
118
+ await _retire(consent_task)
119
+ return _ConsentRace(stream_ended=True)
120
+ if _is_foreign_frame(nxt, task_id):
121
+ render_foreign_frame(nxt, sink)
122
+ continue
123
+ if nxt.get("type") == "confirm_request" and nxt.get("req_id") == rid:
124
+ continue
125
+ await _retire(consent_task)
126
+ _dismiss_consent(sink)
127
+ return _ConsentRace(carry_frame=nxt)
128
+ except BaseException:
129
+ # Hygiene on any unexpected exit (StreamAuthError from the pull,
130
+ # outer cancellation, a consent-task crash): no racer may leak.
131
+ for t in (consent_task, pull):
132
+ if t is not None and not t.done():
133
+ await _retire(t)
134
+ raise
@@ -25,6 +25,13 @@ def _v2_step_label(frame: dict) -> str:
25
25
  return "·".join(x for x in (frame.get("app_id", ""), frame.get("tool", "")) if x)
26
26
 
27
27
 
28
+ def _summary(result: dict) -> str:
29
+ """One-line summary of a tool result for the UI."""
30
+ content = str(result.get("content", ""))
31
+ first = content.strip().splitlines()[0] if content.strip() else ""
32
+ return first[:120]
33
+
34
+
28
35
  def _summary_from_facts(facts: dict) -> str:
29
36
  """Renderer-composed one-line summary from v2 structured summary_facts
30
37
  (I-FRAMES-FACTS-ONLY: the kernel emits facts, never prose here). Degrades
@@ -103,6 +110,21 @@ def handle_step_finished(frame: dict, sink, finished: set, step_labels: dict, st
103
110
  steps.append({"step_id": sid, "label": label, "ok": ok})
104
111
 
105
112
 
113
+ def handle_tool_request(frame: dict, executor) -> dict:
114
+ """Run a (kernel-pre-approved) local tool. The kernel gates write/bash
115
+ consent via confirm_request BEFORE dispatching, so a tool_request here is
116
+ always cleared to run. NEVER raises: a tool that errored (or a bug in the
117
+ executor) must STILL return a result so the caller posts it back and the
118
+ kernel's dispatch unblocks — an unposted result hangs the turn and freezes
119
+ the whole dock."""
120
+ rid = frame.get("req_id")
121
+ try:
122
+ result = executor.run(frame.get("tool", ""), frame.get("args", {}))
123
+ except Exception as e:
124
+ result = {"ok": False, "content": f"local tool crashed: {type(e).__name__}: {e}"}
125
+ return {"req_id": rid, "result": result}
126
+
127
+
106
128
  def handle_action_frame(frame: dict, sink, started: set, finished: set, steps: list) -> None:
107
129
  """OLD R2 ext-tool 'action' start/done frame -> sink.tool_start/
108
130
  tool_result, deduped by step_id against the v2 twin (dual-emit compat
@@ -135,6 +157,16 @@ _FOREIGN_ACTIONABLE_TYPES = ("tool_request", "confirm_request", "final",
135
157
  "marathon_complete", "panel_release_required")
136
158
 
137
159
 
160
+ def _is_foreign_frame(frame: dict, own_task_id: str) -> bool:
161
+ """C7 filter predicate (extracted verbatim from run()'s inline check): a
162
+ frame stamped with a DIFFERENT task_id on the shared persistent stream,
163
+ when it is origin-stamped or actionable, belongs to another turn and is
164
+ DISPLAY-ONLY for this client. task_id absent (legacy kernels) -> own."""
165
+ ftid = frame.get("task_id", "")
166
+ return bool(ftid and own_task_id and ftid != own_task_id and (
167
+ frame.get("origin") or frame.get("type") in _FOREIGN_ACTIONABLE_TYPES))
168
+
169
+
138
170
  def _origin_tag(frame: dict) -> str:
139
171
  """Display prefix for an OWN-turn frame steered from another surface.
140
172
  Live steer topology: a Telegram/panel-steered turn keeps the terminal's
@@ -1,45 +1,16 @@
1
1
  import asyncio
2
2
  import contextlib
3
3
  import os
4
- import subprocess
5
4
  import sys
6
5
 
7
6
  from webbee import __version__
7
+ from webbee.account import login_device_flow
8
+ from webbee.boot import _git_branch, _open_dock_stderr_log, replay_thread, start_intel, start_shadow
8
9
  from webbee.commands import CommandContext, dispatch
9
10
  from webbee.session import AgentSession
10
11
  from webbee.tui import next_mode
11
12
 
12
13
 
13
- def _git_branch(workspace: str) -> str:
14
- try:
15
- p = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=workspace,
16
- capture_output=True, text=True, timeout=5)
17
- return p.stdout.strip() if p.returncode == 0 else "-"
18
- except (OSError, subprocess.SubprocessError):
19
- return "-"
20
-
21
-
22
- def _open_dock_stderr_log():
23
- """A file to swallow stderr for the full-screen dock's lifetime. The dock is
24
- a prompt_toolkit full-screen Application that OWNS the terminal (it diffs the
25
- screen); ANY stray write to stderr while it runs — a dependency's tqdm
26
- download bar, a library warning, a background watcher-task traceback —
27
- desyncs that diff and shows up as overlapping/duplicated text. Routing
28
- stderr to ~/.cache/webbee/tui-stderr.log keeps the dock pixel-clean while
29
- still preserving errors for debugging. Falls back to os.devnull if the cache
30
- dir is unwritable; NEVER raises."""
31
- try:
32
- d = os.path.expanduser("~/.cache/webbee")
33
- os.makedirs(d, exist_ok=True)
34
- return open(os.path.join(d, "tui-stderr.log"), "a", buffering=1, encoding="utf-8")
35
- except Exception:
36
- try:
37
- return open(os.devnull, "w")
38
- except Exception:
39
- import io
40
- return io.StringIO()
41
-
42
-
43
14
  async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
44
15
  agent_factory=None) -> str:
45
16
  """Launch ONE autonomous marathon toward `goal` and stream it to stdout.
@@ -82,28 +53,6 @@ async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
82
53
  return text
83
54
 
84
55
 
85
- def _default_shadow_factory(cfg, workspace: str):
86
- """The reversibility shadow git. Guarded like intel: any failure (no git
87
- binary, cache not writable) degrades to None -- coding still works, just
88
- without the time machine."""
89
- from webbee.checkpoints import ShadowGit, shadow_key
90
- from webbee.repo import find_repo_root
91
- root = find_repo_root(workspace)
92
- sg = ShadowGit(root, shadow_key(root), cache_dir=cfg.cache_dir)
93
- return sg if sg.ensure() else None
94
-
95
-
96
- def _default_intel_factory(cfg, workspace: str):
97
- """Lazy/guarded -- a base install (no tree-sitter/watchfiles extra) must
98
- never fail to import here; `_boot` wraps the whole intel boot in
99
- try/except so any error (missing extra, indexing failure) degrades to
100
- `intel=None` rather than crashing the REPL."""
101
- from webbee.intel.service import IntelService
102
- from webbee.repo import compute_repo_key, find_repo_root
103
- root = find_repo_root(workspace)
104
- return IntelService(root, compute_repo_key(root), cache_dir=cfg.cache_dir)
105
-
106
-
107
56
  async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None, read_line=input,
108
57
  agent_factory=None, auth=None, account_fetcher=None,
109
58
  sessions_client=None, intel_factory=None, shadow_factory=None) -> None:
@@ -158,18 +107,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
158
107
  if res.exit:
159
108
  return "exit"
160
109
  if res.action == "login":
161
- # ONE shared imperal_mcp mechanism: device-code flow (RFC 8628),
162
- # async, so we await it directly on the dock's event loop (the
163
- # /login turn runs as a background task, so the dock stays
164
- # responsive while it polls). on_prompt renders the code + URL
165
- # into the feed — a bare print would be invisible in the dock.
166
- def _login_prompt(user_code, uri, uri_complete):
167
- show = getattr(_sink, "login_prompt", None)
168
- if show:
169
- show(user_code, uri)
170
- else:
171
- _sink.note(f"Open {uri} and enter code: {user_code}")
172
- email = await auth.login_device(cfg, on_prompt=_login_prompt)
110
+ # Device-code flow (RFC 8628) — rendering + polling in webbee.account.
111
+ email = await login_device_flow(cfg, auth, _sink)
173
112
  state["logged_in"] = True
174
113
  _sink.note(f"Signed in as {email}.")
175
114
  return "continue"
@@ -317,47 +256,16 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
317
256
  account = await account_fetcher(cfg, token_provider)
318
257
  state["logged_in"] = account.signed_in
319
258
  _sink.welcome(account, workspace, "terminal")
320
- # Boot replay of the durable per-user thread (Task 9): best-effort,
321
- # entirely swallowed on any failure -- history is a nice-to-have,
322
- # never a boot blocker (network down, no such session yet, etc.).
323
- try:
324
- from imperal_mcp.client import ImperalClient
325
- from webbee.thread import (conversational_text, fetch_recent_thread,
326
- truncate_for_display)
327
- _iid = await ImperalClient(cfg, token_provider).whoami()
328
- _msgs = await fetch_recent_thread(cfg, token_provider, f"marathon-{_iid}-rboot")
329
- _shown = 0
330
- for _m in _msgs[-40:]:
331
- _text = conversational_text(_m.get("content", ""))
332
- if not _text:
333
- continue # pure tool traffic -- mind-food, not conversation
334
- _sink.foreign_turn(_m.get("surface", "terminal"), _m.get("role", ""),
335
- truncate_for_display(_text))
336
- _shown += 1
337
- if _shown:
338
- _sink.note("— live —")
339
- except Exception:
340
- pass # replay is best-effort; never block boot
259
+ # Boot replay of the durable per-user thread (Task 9) best-effort,
260
+ # never a boot blocker (webbee.boot.replay_thread swallows everything).
261
+ await replay_thread(cfg, token_provider, _sink)
341
262
  if cfg.intel_enabled:
342
- # Off-loop build (indexing does sync file I/O + subprocess). Any
343
- # failure here (missing extra, bad repo, etc.) must degrade to
344
- # intel=None, never crash the boot -- coding still works, just
345
- # without repo intelligence.
346
- try:
347
- svc = (intel_factory or _default_intel_factory)(cfg, workspace)
348
- await asyncio.to_thread(svc.build)
349
- intel = svc
350
- from webbee.intel import watch
351
- watcher_task = asyncio.ensure_future(watch.watch_workspace(intel.root, intel.apply_changes))
352
- except Exception:
353
- intel = None
354
- watcher_task = None
263
+ # Guarded off-loop build + watcher; any failure degrades to
264
+ # intel=None, never crashes the boot (webbee.boot.start_intel).
265
+ intel, watcher_task = await start_intel(cfg, workspace, intel_factory)
355
266
  # Whole-mind P4: the reversibility shadow (never the user's VCS);
356
267
  # guarded -- boot must not fail over the time machine.
357
- try:
358
- shadow = await asyncio.to_thread(shadow_factory or _default_shadow_factory, cfg, workspace)
359
- except Exception:
360
- shadow = None
268
+ shadow = await start_shadow(cfg, workspace, shadow_factory)
361
269
  agent = agent_factory(cfg, token_provider, workspace, state["mode"])
362
270
  # Liveness v2 §B: idle-steer pickup. All poll/drain logic lives in
363
271
  # webbee.steer -- this is wiring only: the sink's live turn state
@@ -1,197 +1,23 @@
1
1
  import asyncio
2
- import os
3
- import subprocess
4
- from dataclasses import dataclass
5
2
 
3
+ from webbee.coding_context import build_coding_context, detect_verify_cmd
4
+ from webbee.consent import _retire, race_consent
6
5
  from webbee.frames import (
7
- _FOREIGN_ACTIONABLE_TYPES,
8
6
  _MARATHON_FACT_TYPES,
9
7
  _first_time,
8
+ _is_foreign_frame,
10
9
  _origin_tag,
11
10
  _progress_text,
11
+ _summary,
12
12
  handle_action_frame,
13
13
  handle_step_finished,
14
14
  handle_step_started,
15
+ handle_tool_request,
15
16
  marathon_note,
16
17
  render_foreign_frame,
17
18
  )
18
19
 
19
20
 
20
- def _is_foreign_frame(frame: dict, own_task_id: str) -> bool:
21
- """C7 filter predicate (extracted verbatim from run()'s inline check): a
22
- frame stamped with a DIFFERENT task_id on the shared persistent stream,
23
- when it is origin-stamped or actionable, belongs to another turn and is
24
- DISPLAY-ONLY for this client. task_id absent (legacy kernels) -> own."""
25
- ftid = frame.get("task_id", "")
26
- return bool(ftid and own_task_id and ftid != own_task_id and (
27
- frame.get("origin") or frame.get("type") in _FOREIGN_ACTIONABLE_TYPES))
28
-
29
-
30
- async def _retire(task) -> None:
31
- """Cancel + drain a racing task so it never outlives the frame loop
32
- (liveness A hygiene). Absorbs the loser's outcome — CancelledError, a
33
- late result, StopAsyncIteration, a transport error — retirement must
34
- never introduce a new failure path. An externally-delivered cancellation
35
- of the CALLER (task finished before our cancel landed) still propagates."""
36
- task.cancel()
37
- try:
38
- await task
39
- except asyncio.CancelledError:
40
- if not task.cancelled():
41
- raise # ours was the outer cancel — propagate
42
- except Exception:
43
- pass
44
-
45
-
46
- def _dismiss_consent(sink) -> None:
47
- """Tell the UI the pending consent prompt was answered on ANOTHER surface
48
- (liveness A). Guarded like the other optional sink hooks — a minimal sink
49
- without `consent_dismissed` (or a UI bug) must never break the frame loop."""
50
- fn = getattr(sink, "consent_dismissed", None)
51
- if fn is None:
52
- return
53
- try:
54
- fn("↩ answered from another surface")
55
- except Exception:
56
- pass
57
-
58
-
59
- @dataclass
60
- class _ConsentRace:
61
- """Outcome of racing the local consent prompt against the stream
62
- (liveness A). Exactly one shape per race:
63
- * out — the prompt resolved locally (today's path): POST it; a
64
- pulled-ahead __anext__ may ride along in carry_task.
65
- * carry_frame — the stream won: the park ended elsewhere; the prompt was
66
- cancelled + dismissed, nothing is posted, and the pulled
67
- frame is handed back to the loop for normal handling.
68
- * stream_ended — StopAsyncIteration mid-consent: exit as stream end.
69
- """
70
- out: dict | None = None
71
- carry_task: asyncio.Task | None = None
72
- carry_frame: dict | None = None
73
- stream_ended: bool = False
74
-
75
-
76
- def handle_tool_request(frame: dict, executor) -> dict:
77
- """Run a (kernel-pre-approved) local tool. The kernel gates write/bash
78
- consent via confirm_request BEFORE dispatching, so a tool_request here is
79
- always cleared to run. NEVER raises: a tool that errored (or a bug in the
80
- executor) must STILL return a result so the caller posts it back and the
81
- kernel's dispatch unblocks — an unposted result hangs the turn and freezes
82
- the whole dock."""
83
- rid = frame.get("req_id")
84
- try:
85
- result = executor.run(frame.get("tool", ""), frame.get("args", {}))
86
- except Exception as e:
87
- result = {"ok": False, "content": f"local tool crashed: {type(e).__name__}: {e}"}
88
- return {"req_id": rid, "result": result}
89
-
90
-
91
- async def handle_confirm_request(frame: dict, mode: str, ask_consent) -> dict:
92
- """ICNLI consent path. The client does NOT interpret consent words — it
93
- relays the user's RAW reply; the kernel brain interprets intent
94
- (safe-by-default). autopilot/plan are explicit and never prompt.
95
- `ask_consent(app_id, tool, args)` is an async coroutine returning the raw
96
- reply (resolved through the pinned dock, or a sync fallback reader)."""
97
- req_id = frame["req_id"]
98
- if mode == "autopilot":
99
- return {"req_id": req_id, "result": {"approved": True}}
100
- if mode == "plan":
101
- return {"req_id": req_id, "result": {"approved": False, "reason": "plan_mode"}}
102
- try:
103
- raw = await ask_consent(frame.get("app_id", ""), frame.get("tool", ""), frame.get("args", {}))
104
- except Exception:
105
- # Consent UI failed -> decline (safe-by-default). NEVER leave the kernel
106
- # hanging on an unanswered confirm — an unposted result froze the dock.
107
- return {"req_id": req_id, "result": {"approved": False, "reason": "consent_error"}}
108
- return {"req_id": req_id, "result": {"consent_reply": raw}}
109
-
110
-
111
- def build_coding_context(workspace_root: str, intel=None) -> dict:
112
- """Snapshot handed to the cloud brain: cwd (realpath), `git status -sb`
113
- (empty for non-git/any error), a bounded newline-joined file tree, and —
114
- when a ready intel service is injected — the precomputed repo_profile.
115
- The profile is READ from the already-built index (cheap); indexing never
116
- happens inline here."""
117
- cwd = os.path.realpath(workspace_root)
118
- try:
119
- proc = subprocess.run(
120
- ["git", "status", "-sb"], cwd=cwd,
121
- capture_output=True, text=True, timeout=10,
122
- )
123
- git = proc.stdout if proc.returncode == 0 else ""
124
- except (OSError, subprocess.SubprocessError):
125
- git = ""
126
- paths = []
127
- for dirpath, dirnames, filenames in os.walk(cwd):
128
- dirnames[:] = [d for d in dirnames if d != ".git" and not d.startswith(".")]
129
- for fn in filenames:
130
- paths.append(os.path.relpath(os.path.join(dirpath, fn), cwd))
131
- if len(paths) >= 200:
132
- break
133
- if len(paths) >= 200:
134
- break
135
- from webbee.repo import compute_repo_key, find_repo_root
136
- root = find_repo_root(cwd)
137
- d = {"cwd": cwd, "git": git, "tree": "\n".join(paths),
138
- "repo_key": compute_repo_key(root), "repo_root": root}
139
- if intel is not None and getattr(intel, "ready", False):
140
- try:
141
- d["repo_profile"] = intel.repo_profile()
142
- except Exception:
143
- pass
144
- return d
145
-
146
-
147
- def detect_verify_cmd(repo_root: str) -> str:
148
- """Best-effort project test command, CLIENT-detected from the repo layout.
149
-
150
- SECURITY-load-bearing: in a marathon the kernel runs ONLY this command as
151
- proof-of-done — the cloud brain can never author a shell command. So this
152
- is deliberately small + honest: a fixed command per recognised ecosystem,
153
- NO guessing beyond these. An empty string means "no known runner" → the
154
- kernel falls back to an LLM-judged done-check. Checked in a stable order."""
155
- import json as _json
156
-
157
- root = repo_root or "."
158
-
159
- def _has(name: str) -> bool:
160
- return os.path.isfile(os.path.join(root, name))
161
-
162
- if _has("pyproject.toml") or _has("setup.cfg") or _has("tox.ini"):
163
- return "pytest -q"
164
- if _has("package.json"):
165
- try:
166
- with open(os.path.join(root, "package.json"), encoding="utf-8") as f:
167
- pkg = _json.load(f)
168
- scripts = pkg.get("scripts") if isinstance(pkg, dict) else None
169
- if isinstance(scripts, dict) and scripts.get("test"):
170
- return "npm test"
171
- except (OSError, ValueError):
172
- pass
173
- if _has("Cargo.toml"):
174
- return "cargo test"
175
- if _has("go.mod"):
176
- return "go test ./..."
177
- if _has("Makefile"):
178
- try:
179
- import re as _re
180
- with open(os.path.join(root, "Makefile"), encoding="utf-8") as f:
181
- if _re.search(r"(?m)^test:", f.read()):
182
- return "make test"
183
- except OSError:
184
- pass
185
- return ""
186
-
187
-
188
- def _summary(result: dict) -> str:
189
- """One-line summary of a tool result for the UI."""
190
- content = str(result.get("content", ""))
191
- first = content.strip().splitlines()[0] if content.strip() else ""
192
- return first[:120]
193
-
194
-
195
21
  class AgentSession:
196
22
  """Client-side driver for one coding turn against the Imperal cloud.
197
23
  The brain runs server-side; this is the hands — it streams kernel-
@@ -357,7 +183,8 @@ class AgentSession:
357
183
  # consent answered from ANOTHER surface (Telegram
358
184
  # relay) unfreezes this terminal instead of leaving
359
185
  # the dock stuck on `approve? y/n`.
360
- race = await self._race_consent(frame, sink, stream)
186
+ race = await race_consent(frame, sink, stream,
187
+ mode=self.mode, task_id=self._task_id)
361
188
  carry_task, carry_frame = race.carry_task, race.carry_frame
362
189
  if race.stream_ended:
363
190
  break
@@ -425,63 +252,6 @@ class AgentSession:
425
252
 
426
253
  return ""
427
254
 
428
- async def _race_consent(self, frame: dict, sink, stream) -> _ConsentRace:
429
- """LIVE BUG fix (Valentin, 2026-07-15): a consent answered from
430
- Telegram resolves the kernel park, but the frame loop used to block
431
- INLINE in handle_confirm_request — the dock froze on `approve? y/n`
432
- and the rest of the turn never rendered until a local keypress. The
433
- local prompt now RACES the stream:
434
- * the prompt resolves first -> POST it (today's path, unchanged);
435
- the pulled-ahead frame rides back to the loop in carry_task.
436
- * a re-published confirm_request with the SAME req_id (kernel
437
- I-MARATHON-USER-WAKE presence re-publish) -> NOT an answer; the
438
- prompt stays up and both racers keep going.
439
- * any other OWN-turn frame -> the park ended elsewhere (answered
440
- from another surface / superseded): cancel the prompt, dismiss the
441
- dock, POST NOTHING (the kernel accepts only the FIRST result per
442
- issued req_id), hand the frame back for normal handling.
443
- * foreign frames (C7) stay display-only and never end the park.
444
- Consent is never auto-approved here — autopilot/plan resolve instantly
445
- inside handle_confirm_request exactly as before."""
446
- rid = frame.get("req_id")
447
- consent_task = asyncio.ensure_future(
448
- handle_confirm_request(frame, self.mode, sink.ask_consent))
449
- pull = None
450
- try:
451
- while True:
452
- if pull is None:
453
- pull = asyncio.ensure_future(stream.__anext__())
454
- await asyncio.wait({consent_task, pull},
455
- return_when=asyncio.FIRST_COMPLETED)
456
- if consent_task.done():
457
- # The local answer wins — even when both racers finished in
458
- # the same cycle the pulled frame is NOT lost: the loop owns
459
- # carry_task and processes it next.
460
- return _ConsentRace(out=consent_task.result(), carry_task=pull)
461
- nxt_pull, pull = pull, None
462
- try:
463
- nxt = nxt_pull.result()
464
- except StopAsyncIteration:
465
- # Stream ended mid-consent: retire the prompt safely and
466
- # exit exactly as today's stream end (no result posted).
467
- await _retire(consent_task)
468
- return _ConsentRace(stream_ended=True)
469
- if _is_foreign_frame(nxt, self._task_id):
470
- render_foreign_frame(nxt, sink)
471
- continue
472
- if nxt.get("type") == "confirm_request" and nxt.get("req_id") == rid:
473
- continue
474
- await _retire(consent_task)
475
- _dismiss_consent(sink)
476
- return _ConsentRace(carry_frame=nxt)
477
- except BaseException:
478
- # Hygiene on any unexpected exit (StreamAuthError from the pull,
479
- # outer cancellation, a consent-task crash): no racer may leak.
480
- for t in (consent_task, pull):
481
- if t is not None and not t.done():
482
- await _retire(t)
483
- raise
484
-
485
255
  async def _post_result(self, client, session_id: str, out: dict) -> None:
486
256
  # Best-effort: a result POST must NEVER raise into the stream loop — that
487
257
  # would abort the turn and leave the kernel's dispatch hanging (frozen
@@ -27,7 +27,7 @@ async def derive_session_id(cfg, token_provider, workspace: str, *,
27
27
  turn has run: the gateway keys coding sessions stable per user+repo as
28
28
  `{marathon|coding}-{imperal_id}-r{repo_key}` (agent_sessions/router.py)
29
29
  from the repo_key the client itself sends in coding_context
30
- (session.build_coding_context -> repo.compute_repo_key). The boot-replay
30
+ (coding_context.build_coding_context -> repo.compute_repo_key). The boot-replay
31
31
  placeholder (`marathon-{iid}-rboot`) would pass the gateway's ownership
32
32
  prefix check but MISS the per-session remote-control state key
33
33
  (resolve_state reads k_state(session_id) -- set by /notify under the real
@@ -7,7 +7,8 @@ terminal). Every path must return a result so the kernel unblocks."""
7
7
  import asyncio
8
8
 
9
9
  from webbee.tools import LocalToolExecutor
10
- from webbee.session import handle_tool_request, handle_confirm_request
10
+ from webbee.consent import handle_confirm_request
11
+ from webbee.frames import handle_tool_request
11
12
 
12
13
 
13
14
  def test_outside_workspace_returns_result_not_raise(tmp_path):
@@ -10,7 +10,7 @@ Covers the two SECURITY/behaviour-load-bearing pieces:
10
10
  """
11
11
  import asyncio
12
12
 
13
- from webbee.session import detect_verify_cmd
13
+ from webbee.coding_context import detect_verify_cmd
14
14
 
15
15
 
16
16
  # --- detect_verify_cmd -------------------------------------------------------
@@ -1,6 +1,8 @@
1
1
  import asyncio
2
2
 
3
- from webbee.session import handle_tool_request, handle_confirm_request, build_coding_context
3
+ from webbee.coding_context import build_coding_context
4
+ from webbee.consent import handle_confirm_request
5
+ from webbee.frames import handle_tool_request
4
6
 
5
7
 
6
8
  class RecordingExecutor:
@@ -529,7 +531,7 @@ def test_local_tool_v2_frames_dont_double_the_tool_request_render():
529
531
  # EXISTING mechanism, then step_finished. Only ONE start/result pair
530
532
  # must reach the sink.
531
533
  from webbee.frames import handle_step_started, handle_step_finished, _first_time
532
- from webbee.session import _summary
534
+ from webbee.frames import _summary
533
535
  sink = RecSink()
534
536
  started, finished, labels, steps, local_ids = set(), set(), {}, [], set()
535
537
  handle_step_started({"step_id": "toolu_1", "kind": "local_tool", "tool": "read_file"},
@@ -8,8 +8,8 @@ download progress bars at the entry point; (2) route stderr to a log file for
8
8
  the dock's whole lifetime."""
9
9
  import os
10
10
 
11
+ from webbee.boot import _open_dock_stderr_log
11
12
  from webbee.cli import _quiet_downloads
12
- from webbee.repl import _open_dock_stderr_log
13
13
 
14
14
 
15
15
  def test_quiet_downloads_sets_env(monkeypatch):
@@ -1 +0,0 @@
1
- __version__ = "0.3.8"
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