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.
- {webbee-0.3.8 → webbee-0.3.9}/CHANGELOG.md +6 -0
- {webbee-0.3.8 → webbee-0.3.9}/PKG-INFO +1 -1
- {webbee-0.3.8 → webbee-0.3.9}/pyproject.toml +1 -1
- webbee-0.3.9/src/webbee/__init__.py +1 -0
- {webbee-0.3.8 → 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.8 → webbee-0.3.9}/src/webbee/frames.py +32 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/repl.py +11 -103
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/session.py +7 -237
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/steer.py +1 -1
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_freeze_fix.py +2 -1
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_marathon.py +1 -1
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_session.py +4 -2
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_tui_hardening.py +1 -1
- webbee-0.3.8/src/webbee/__init__.py +0 -1
- {webbee-0.3.8 → webbee-0.3.9}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/.gitignore +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/LICENSE +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/README.md +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/install.sh +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/cli.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/commands.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/config.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/details.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/events.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/remote.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/render.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/repo.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/sessions.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/stream.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/thread.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/tokens.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/tools.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/tui.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/src/webbee/update.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/__init__.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_account.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_cli.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_clipboard.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_commands.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_config.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_details.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_events.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_query.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_service.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_store.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_packaging.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_render.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_repl.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_repo.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_sessions.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_steer.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_stream.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_thread.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_tokens.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_tools.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_tui.py +0 -0
- {webbee-0.3.8 → webbee-0.3.9}/tests/test_update.py +0 -0
- {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
|
|
@@ -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
|
-
#
|
|
162
|
-
|
|
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)
|
|
321
|
-
#
|
|
322
|
-
|
|
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
|
-
#
|
|
343
|
-
#
|
|
344
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|