webbee 0.3.7__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.7 → webbee-0.3.9}/CHANGELOG.md +16 -0
  2. {webbee-0.3.7 → webbee-0.3.9}/PKG-INFO +1 -1
  3. {webbee-0.3.7 → webbee-0.3.9}/pyproject.toml +1 -1
  4. webbee-0.3.9/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.7 → 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.7 → webbee-0.3.9}/src/webbee/frames.py +32 -0
  10. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/repl.py +19 -107
  11. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/session.py +7 -237
  12. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/steer.py +8 -2
  13. webbee-0.3.9/src/webbee/tokens.py +39 -0
  14. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/tui.py +17 -7
  15. {webbee-0.3.7 → webbee-0.3.9}/tests/test_freeze_fix.py +2 -1
  16. {webbee-0.3.7 → webbee-0.3.9}/tests/test_marathon.py +1 -1
  17. {webbee-0.3.7 → webbee-0.3.9}/tests/test_repl.py +8 -2
  18. {webbee-0.3.7 → webbee-0.3.9}/tests/test_session.py +4 -2
  19. webbee-0.3.9/tests/test_tokens.py +57 -0
  20. {webbee-0.3.7 → webbee-0.3.9}/tests/test_tui_hardening.py +1 -1
  21. webbee-0.3.7/src/webbee/__init__.py +0 -1
  22. {webbee-0.3.7 → webbee-0.3.9}/.github/workflows/publish.yml +0 -0
  23. {webbee-0.3.7 → webbee-0.3.9}/.gitignore +0 -0
  24. {webbee-0.3.7 → webbee-0.3.9}/LICENSE +0 -0
  25. {webbee-0.3.7 → webbee-0.3.9}/README.md +0 -0
  26. {webbee-0.3.7 → webbee-0.3.9}/install.sh +0 -0
  27. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/banner_art.py +0 -0
  28. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/checkpoints.py +0 -0
  29. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/cli.py +0 -0
  30. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/clipboard.py +0 -0
  31. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/commands.py +0 -0
  32. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/config.py +0 -0
  33. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/details.py +0 -0
  34. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/events.py +0 -0
  35. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/__init__.py +0 -0
  36. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/chunker.py +0 -0
  37. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/embed.py +0 -0
  38. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/graph.py +0 -0
  39. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/indexer.py +0 -0
  40. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/models.py +0 -0
  41. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/query.py +0 -0
  42. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/service.py +0 -0
  43. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/store.py +0 -0
  44. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/vectors.py +0 -0
  45. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/watch.py +0 -0
  46. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/output_pane.py +0 -0
  47. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/remote.py +0 -0
  48. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/render.py +0 -0
  49. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/repo.py +0 -0
  50. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/sessions.py +0 -0
  51. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/stream.py +0 -0
  52. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/thread.py +0 -0
  53. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/tools.py +0 -0
  54. {webbee-0.3.7 → webbee-0.3.9}/src/webbee/update.py +0 -0
  55. {webbee-0.3.7 → webbee-0.3.9}/tests/__init__.py +0 -0
  56. {webbee-0.3.7 → webbee-0.3.9}/tests/test_account.py +0 -0
  57. {webbee-0.3.7 → webbee-0.3.9}/tests/test_checkpoints.py +0 -0
  58. {webbee-0.3.7 → webbee-0.3.9}/tests/test_cli.py +0 -0
  59. {webbee-0.3.7 → webbee-0.3.9}/tests/test_clipboard.py +0 -0
  60. {webbee-0.3.7 → webbee-0.3.9}/tests/test_commands.py +0 -0
  61. {webbee-0.3.7 → webbee-0.3.9}/tests/test_config.py +0 -0
  62. {webbee-0.3.7 → webbee-0.3.9}/tests/test_cpc_contract_stable.py +0 -0
  63. {webbee-0.3.7 → webbee-0.3.9}/tests/test_details.py +0 -0
  64. {webbee-0.3.7 → webbee-0.3.9}/tests/test_events.py +0 -0
  65. {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_chunker.py +0 -0
  66. {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_embed.py +0 -0
  67. {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_graph.py +0 -0
  68. {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_indexer.py +0 -0
  69. {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_no_numpy.py +0 -0
  70. {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_query.py +0 -0
  71. {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_service.py +0 -0
  72. {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_store.py +0 -0
  73. {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_vectors.py +0 -0
  74. {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_watch.py +0 -0
  75. {webbee-0.3.7 → webbee-0.3.9}/tests/test_packaging.py +0 -0
  76. {webbee-0.3.7 → webbee-0.3.9}/tests/test_render.py +0 -0
  77. {webbee-0.3.7 → webbee-0.3.9}/tests/test_repo.py +0 -0
  78. {webbee-0.3.7 → webbee-0.3.9}/tests/test_sessions.py +0 -0
  79. {webbee-0.3.7 → webbee-0.3.9}/tests/test_steer.py +0 -0
  80. {webbee-0.3.7 → webbee-0.3.9}/tests/test_stream.py +0 -0
  81. {webbee-0.3.7 → webbee-0.3.9}/tests/test_thread.py +0 -0
  82. {webbee-0.3.7 → webbee-0.3.9}/tests/test_tools.py +0 -0
  83. {webbee-0.3.7 → webbee-0.3.9}/tests/test_tui.py +0 -0
  84. {webbee-0.3.7 → webbee-0.3.9}/tests/test_update.py +0 -0
  85. {webbee-0.3.7 → webbee-0.3.9}/tests/test_version.py +0 -0
@@ -1,5 +1,21 @@
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
+
9
+ ## 0.3.8
10
+
11
+ - No more sign-in races: token refresh is serialized and retries once after
12
+ a sibling terminal rotates the session — multiple open terminals no longer
13
+ knock each other out with "session expired".
14
+ - No more dock lockup: a turn that fails (for example, an expired session)
15
+ now clears the "working" state, and every key keeps responding even if the
16
+ turn state ever goes stale. The idle pickup poller also backs off instead
17
+ of hammering a signed-out session.
18
+
3
19
  ## 0.3.7
4
20
 
5
21
  - 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.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.7"
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.
@@ -58,8 +29,8 @@ async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
58
29
 
59
30
  workspace = os.getcwd()
60
31
 
61
- async def token_provider() -> str:
62
- return await auth.ensure_access_token(cfg)
32
+ from webbee.tokens import make_token_provider
33
+ token_provider = make_token_provider(cfg, auth)
63
34
 
64
35
  if agent_factory is None:
65
36
  agent_factory = lambda c, tp, ws, m: AgentSession(c, tp, ws, m) # noqa: E731
@@ -72,36 +43,16 @@ async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
72
43
  except (KeyboardInterrupt, asyncio.CancelledError):
73
44
  await agent.stop()
74
45
  sink.note("Interrupted.")
46
+ sink.end_turn("") # clear busy (poller starvation guard)
75
47
  return ""
76
48
  except Exception as e: # network/auth/etc — never crash
77
49
  sink.note(f"Error: {type(e).__name__}: {e}")
50
+ sink.end_turn("") # clear busy: a stuck 'working' also starves the idle-steer poller
78
51
  return ""
79
52
  sink.end_turn(text)
80
53
  return text
81
54
 
82
55
 
83
- def _default_shadow_factory(cfg, workspace: str):
84
- """The reversibility shadow git. Guarded like intel: any failure (no git
85
- binary, cache not writable) degrades to None -- coding still works, just
86
- without the time machine."""
87
- from webbee.checkpoints import ShadowGit, shadow_key
88
- from webbee.repo import find_repo_root
89
- root = find_repo_root(workspace)
90
- sg = ShadowGit(root, shadow_key(root), cache_dir=cfg.cache_dir)
91
- return sg if sg.ensure() else None
92
-
93
-
94
- def _default_intel_factory(cfg, workspace: str):
95
- """Lazy/guarded -- a base install (no tree-sitter/watchfiles extra) must
96
- never fail to import here; `_boot` wraps the whole intel boot in
97
- try/except so any error (missing extra, indexing failure) degrades to
98
- `intel=None` rather than crashing the REPL."""
99
- from webbee.intel.service import IntelService
100
- from webbee.repo import compute_repo_key, find_repo_root
101
- root = find_repo_root(workspace)
102
- return IntelService(root, compute_repo_key(root), cache_dir=cfg.cache_dir)
103
-
104
-
105
56
  async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None, read_line=input,
106
57
  agent_factory=None, auth=None, account_fetcher=None,
107
58
  sessions_client=None, intel_factory=None, shadow_factory=None) -> None:
@@ -122,8 +73,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
122
73
 
123
74
  workspace = os.getcwd()
124
75
 
125
- async def token_provider() -> str:
126
- return await auth.ensure_access_token(cfg)
76
+ from webbee.tokens import make_token_provider
77
+ token_provider = make_token_provider(cfg, auth)
127
78
 
128
79
  # Prod dock path = the default reader + a real tty + no injected sink; tests
129
80
  # inject sink/read_line and take the plain fallback loop.
@@ -156,18 +107,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
156
107
  if res.exit:
157
108
  return "exit"
158
109
  if res.action == "login":
159
- # ONE shared imperal_mcp mechanism: device-code flow (RFC 8628),
160
- # async, so we await it directly on the dock's event loop (the
161
- # /login turn runs as a background task, so the dock stays
162
- # responsive while it polls). on_prompt renders the code + URL
163
- # into the feed — a bare print would be invisible in the dock.
164
- def _login_prompt(user_code, uri, uri_complete):
165
- show = getattr(_sink, "login_prompt", None)
166
- if show:
167
- show(user_code, uri)
168
- else:
169
- _sink.note(f"Open {uri} and enter code: {user_code}")
170
- 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)
171
112
  state["logged_in"] = True
172
113
  _sink.note(f"Signed in as {email}.")
173
114
  return "continue"
@@ -287,9 +228,11 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
287
228
  except (KeyboardInterrupt, asyncio.CancelledError):
288
229
  _sink.abort()
289
230
  _sink.note("Interrupted.")
231
+ _sink.end_turn("") # clear busy (poller starvation guard)
290
232
  return
291
233
  except Exception as e: # network/auth/etc — never crash the REPL
292
234
  _sink.note(f"Error: {type(e).__name__}: {e}")
235
+ _sink.end_turn("") # clear busy: a stuck 'working' also starves the idle-steer poller
293
236
  return
294
237
  _sink.end_turn(text)
295
238
 
@@ -313,47 +256,16 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
313
256
  account = await account_fetcher(cfg, token_provider)
314
257
  state["logged_in"] = account.signed_in
315
258
  _sink.welcome(account, workspace, "terminal")
316
- # Boot replay of the durable per-user thread (Task 9): best-effort,
317
- # entirely swallowed on any failure -- history is a nice-to-have,
318
- # never a boot blocker (network down, no such session yet, etc.).
319
- try:
320
- from imperal_mcp.client import ImperalClient
321
- from webbee.thread import (conversational_text, fetch_recent_thread,
322
- truncate_for_display)
323
- _iid = await ImperalClient(cfg, token_provider).whoami()
324
- _msgs = await fetch_recent_thread(cfg, token_provider, f"marathon-{_iid}-rboot")
325
- _shown = 0
326
- for _m in _msgs[-40:]:
327
- _text = conversational_text(_m.get("content", ""))
328
- if not _text:
329
- continue # pure tool traffic -- mind-food, not conversation
330
- _sink.foreign_turn(_m.get("surface", "terminal"), _m.get("role", ""),
331
- truncate_for_display(_text))
332
- _shown += 1
333
- if _shown:
334
- _sink.note("— live —")
335
- except Exception:
336
- 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)
337
262
  if cfg.intel_enabled:
338
- # Off-loop build (indexing does sync file I/O + subprocess). Any
339
- # failure here (missing extra, bad repo, etc.) must degrade to
340
- # intel=None, never crash the boot -- coding still works, just
341
- # without repo intelligence.
342
- try:
343
- svc = (intel_factory or _default_intel_factory)(cfg, workspace)
344
- await asyncio.to_thread(svc.build)
345
- intel = svc
346
- from webbee.intel import watch
347
- watcher_task = asyncio.ensure_future(watch.watch_workspace(intel.root, intel.apply_changes))
348
- except Exception:
349
- intel = None
350
- 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)
351
266
  # Whole-mind P4: the reversibility shadow (never the user's VCS);
352
267
  # guarded -- boot must not fail over the time machine.
353
- try:
354
- shadow = await asyncio.to_thread(shadow_factory or _default_shadow_factory, cfg, workspace)
355
- except Exception:
356
- shadow = None
268
+ shadow = await start_shadow(cfg, workspace, shadow_factory)
357
269
  agent = agent_factory(cfg, token_provider, workspace, state["mode"])
358
270
  # Liveness v2 §B: idle-steer pickup. All poll/drain logic lives in
359
271
  # webbee.steer -- this is wiring only: the sink's live turn state