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.
- {webbee-0.3.7 → webbee-0.3.9}/CHANGELOG.md +16 -0
- {webbee-0.3.7 → webbee-0.3.9}/PKG-INFO +1 -1
- {webbee-0.3.7 → webbee-0.3.9}/pyproject.toml +1 -1
- webbee-0.3.9/src/webbee/__init__.py +1 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/account.py +15 -0
- webbee-0.3.9/src/webbee/boot.py +108 -0
- webbee-0.3.9/src/webbee/coding_context.py +83 -0
- webbee-0.3.9/src/webbee/consent.py +134 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/frames.py +32 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/repl.py +19 -107
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/session.py +7 -237
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/steer.py +8 -2
- webbee-0.3.9/src/webbee/tokens.py +39 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/tui.py +17 -7
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_freeze_fix.py +2 -1
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_marathon.py +1 -1
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_repl.py +8 -2
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_session.py +4 -2
- webbee-0.3.9/tests/test_tokens.py +57 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_tui_hardening.py +1 -1
- webbee-0.3.7/src/webbee/__init__.py +0 -1
- {webbee-0.3.7 → webbee-0.3.9}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/.gitignore +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/LICENSE +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/README.md +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/install.sh +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/cli.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/commands.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/config.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/details.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/events.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/remote.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/render.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/repo.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/sessions.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/stream.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/thread.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/tools.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/src/webbee/update.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/__init__.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_account.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_cli.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_clipboard.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_commands.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_config.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_details.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_events.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_query.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_service.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_store.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_packaging.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_render.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_repo.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_sessions.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_steer.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_stream.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_thread.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_tools.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_tui.py +0 -0
- {webbee-0.3.7 → webbee-0.3.9}/tests/test_update.py +0 -0
- {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
|
|
@@ -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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
#
|
|
160
|
-
|
|
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)
|
|
317
|
-
#
|
|
318
|
-
|
|
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
|
-
#
|
|
339
|
-
#
|
|
340
|
-
|
|
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
|
-
|
|
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
|