webbee 0.3.12__tar.gz → 0.3.14__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.12 → webbee-0.3.14}/CHANGELOG.md +22 -0
- {webbee-0.3.12 → webbee-0.3.14}/PKG-INFO +1 -1
- {webbee-0.3.12 → webbee-0.3.14}/install.sh +1 -1
- {webbee-0.3.12 → webbee-0.3.14}/pyproject.toml +1 -1
- webbee-0.3.14/src/webbee/__init__.py +1 -0
- webbee-0.3.14/src/webbee/queue_panel.py +127 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/render.py +105 -33
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/repl.py +62 -7
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/session.py +24 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/steer.py +41 -2
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/thread.py +15 -9
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/tools.py +114 -4
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/tui.py +89 -33
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_render.py +155 -18
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_repl.py +95 -1
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_session.py +102 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_steer.py +117 -13
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_thread.py +40 -5
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_tools.py +167 -10
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_tui.py +348 -32
- webbee-0.3.12/src/webbee/__init__.py +0 -1
- {webbee-0.3.12 → webbee-0.3.14}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/.gitignore +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/LICENSE +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/README.md +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/account.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/boot.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/cli.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/commands.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/config.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/consent.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/details.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/events.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/frames.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/remote.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/repo.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/sessions.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/stream.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/tokens.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/src/webbee/update.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/__init__.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_account.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_cli.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_clipboard.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_commands.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_config.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_details.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_events.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_intel_query.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_intel_service.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_intel_store.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_marathon.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_packaging.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_repo.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_sessions.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_stream.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_tokens.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_update.py +0 -0
- {webbee-0.3.12 → webbee-0.3.14}/tests/test_version.py +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.14
|
|
4
|
+
|
|
5
|
+
- See queued messages from Telegram/panel right in the terminal queue
|
|
6
|
+
(tagged by origin), and set the coding mode from Telegram — switching to
|
|
7
|
+
autopilot asks you to confirm in the terminal first.
|
|
8
|
+
- Reading a file now shows its size and freshness up front — line count,
|
|
9
|
+
when it was last modified, what it defines, and what depends on it — so
|
|
10
|
+
Webbee reads big files smartly and never edits a stale one; an edit to a
|
|
11
|
+
file that changed on disk since it was read gets a re-read nudge.
|
|
12
|
+
- The code-intelligence graph now installs by default (`webbee[intel]`) —
|
|
13
|
+
symbol awareness + repo relationships are on out of the box, not opt-in.
|
|
14
|
+
|
|
15
|
+
## 0.3.13
|
|
16
|
+
|
|
17
|
+
- The message queue is now a live panel above the input — you see exactly
|
|
18
|
+
what's waiting, press ↑ to pull the last one back for editing (it leaves
|
|
19
|
+
the queue and returns when you resend), or click one to edit it. The
|
|
20
|
+
transcript stays clean.
|
|
21
|
+
- Cleaner welcome screen — just the essentials (who you are, your plan, how
|
|
22
|
+
to start) plus a clear word on privacy: your work is never sold and never
|
|
23
|
+
used to train models, and PII is masked before it reaches the model.
|
|
24
|
+
|
|
3
25
|
## 0.3.12
|
|
4
26
|
|
|
5
27
|
- Queued messages are now visible — when you type while Webbee is working,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.14"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""The LIVE pending-queue panel pinned between the output pane and the input
|
|
2
|
+
box (0.3.13 — replaces the static `⋯ queued:` scrollback echoes, which
|
|
3
|
+
scrolled away, duplicated and never updated). The builders here are PURE
|
|
4
|
+
(unit-tested without an Application); tui.run_session mounts them in a
|
|
5
|
+
ConditionalContainer that occupies ZERO rows while the queue is empty, so
|
|
6
|
+
the empty-queue dock is pixel-identical to before. Item rows carry a
|
|
7
|
+
per-fragment mouse handler (prompt_toolkit 3.0.52 3-tuple fragments,
|
|
8
|
+
verified in venv) firing on MOUSE_UP only — a PASSIVE consumer of the
|
|
9
|
+
clicks that already flow under the ?1000/?1002 button-event tracking; it
|
|
10
|
+
enables nothing at the terminal level, so the 0.3.3 mouse-flood fix
|
|
11
|
+
(?1003 any-event stays off, tui.configure_mouse_modes) is untouched.
|
|
12
|
+
Split out of tui.py to keep both files under the file-size ceiling."""
|
|
13
|
+
|
|
14
|
+
QP_MAX_ITEMS = 5 # newest items shown; deeper queues add one `… +K more` row
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def one_line(text: str, width: int) -> str:
|
|
18
|
+
"""PURE. Collapse whitespace/newlines so a queued item costs EXACTLY one
|
|
19
|
+
panel row, truncating with `…` when it would overflow `width` columns
|
|
20
|
+
(width<=0 = no truncation — headless/unknown terminal)."""
|
|
21
|
+
t = " ".join((text or "").split())
|
|
22
|
+
if width > 0 and len(t) > width:
|
|
23
|
+
t = t[:max(1, width - 1)] + "…"
|
|
24
|
+
return t
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def queue_height(pending, remote=None) -> int:
|
|
28
|
+
"""PURE. Rows the panel needs: 1 header + one per SHOWN item + one
|
|
29
|
+
`… +K more` row when a queue is deeper than QP_MAX_ITEMS (each of the
|
|
30
|
+
two sections — remote rows and local rows — caps independently). 0 when
|
|
31
|
+
both are empty (the ConditionalContainer hides the panel then anyway).
|
|
32
|
+
The cap keeps the output pane dominant on small terminals; the toolbar's
|
|
33
|
+
`⋯N queued` segment stays the truth-teller for the full depth."""
|
|
34
|
+
n = len(pending)
|
|
35
|
+
r = len(remote or ())
|
|
36
|
+
if not n and not r:
|
|
37
|
+
return 0
|
|
38
|
+
rows = 1
|
|
39
|
+
if r:
|
|
40
|
+
rows += min(r, QP_MAX_ITEMS) + (1 if r > QP_MAX_ITEMS else 0)
|
|
41
|
+
if n:
|
|
42
|
+
rows += min(n, QP_MAX_ITEMS) + (1 if n > QP_MAX_ITEMS else 0)
|
|
43
|
+
return rows
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pull_item(pending, buf, index: int) -> bool:
|
|
47
|
+
"""The ONE pull-to-edit implementation (serves BOTH the ↑ key — newest,
|
|
48
|
+
index len(pending)-1 — and a panel-row click — that row's index): move
|
|
49
|
+
pending[index] OUT of the queue and into the input buffer for editing,
|
|
50
|
+
cursor at the end. Guards, identical on both paths: a buffer with ANY
|
|
51
|
+
text is never clobbered, and a stale index (the queue drained between
|
|
52
|
+
render and click) is ignored. Returns True when a pull happened (the
|
|
53
|
+
caller invalidates)."""
|
|
54
|
+
if buf.text or not (0 <= index < len(pending)):
|
|
55
|
+
return False
|
|
56
|
+
item = pending[index]
|
|
57
|
+
del pending[index]
|
|
58
|
+
buf.text = item
|
|
59
|
+
buf.cursor_position = len(item)
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _item_handler(pull, index: int):
|
|
64
|
+
"""One row's mouse handler: MOUSE_UP (a click, not a drag/press) pulls
|
|
65
|
+
THAT queued item into the input via `pull(index)`; every other event
|
|
66
|
+
falls through (NotImplemented) so wheel scroll etc. keep today's
|
|
67
|
+
behavior. Mirrors OutputPane._SelectControl's event discipline."""
|
|
68
|
+
def _h(mouse_event):
|
|
69
|
+
from prompt_toolkit.mouse_events import MouseEventType
|
|
70
|
+
if mouse_event.event_type == MouseEventType.MOUSE_UP:
|
|
71
|
+
pull(index)
|
|
72
|
+
return None
|
|
73
|
+
return NotImplemented
|
|
74
|
+
return _h
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def queue_fragments(pending, pull=None, width: int = 0, remote=None):
|
|
78
|
+
"""PURE builder: the panel as prompt_toolkit formatted text, re-invoked
|
|
79
|
+
every redraw (same live mechanics as the toolbar) so every queue
|
|
80
|
+
add/edit/drain shows at once. Layout, top→bottom = drain order (FIFO —
|
|
81
|
+
the TOP row runs next; the BOTTOM row is the newest, the one ↑ pulls,
|
|
82
|
+
sitting right above the input):
|
|
83
|
+
|
|
84
|
+
⋯ queued (N) · ↑ edit last · click to edit
|
|
85
|
+
[telegram] remote item ← remote rows ABOVE local (qp.remote)
|
|
86
|
+
… +K more ← only when N > QP_MAX_ITEMS (the OLDEST hide)
|
|
87
|
+
older item ← muted (qp.item)
|
|
88
|
+
newest item ← accent (qp.last)
|
|
89
|
+
|
|
90
|
+
`remote` (full-queue-layer K1) = cross-surface items already queued in
|
|
91
|
+
the RUNNING kernel session ([{origin, text, iid}]); the kernel drains
|
|
92
|
+
its own queue first, mid-run, while local type-ahead only runs after the
|
|
93
|
+
whole turn returns — so remote rows render ABOVE local and top→bottom
|
|
94
|
+
stays drain order. They are DISPLAY-ONLY: tagged `[origin]`, plain
|
|
95
|
+
2-tuple fragments (never a mouse handler) and never part of the pull
|
|
96
|
+
index space — you can't pull a kernel-queued item into the local input.
|
|
97
|
+
The header counts both; the `↑ edit last` hint shows only when there is
|
|
98
|
+
a local (pullable) item. When `pull` is given each LOCAL item row is a
|
|
99
|
+
3-tuple fragment carrying a mouse handler that pulls exactly that item
|
|
100
|
+
(see _item_handler). Both queues empty → [] (the panel is hidden)."""
|
|
101
|
+
items = list(pending)
|
|
102
|
+
rem = [r for r in (remote or ()) if isinstance(r, dict)]
|
|
103
|
+
n = len(items)
|
|
104
|
+
if not n and not rem:
|
|
105
|
+
return []
|
|
106
|
+
frags = [("class:qp.header", f" ⋯ queued ({n + len(rem)})")]
|
|
107
|
+
if n:
|
|
108
|
+
frags.append(("class:qp.item", " · ↑ edit last · click to edit"))
|
|
109
|
+
rstart = max(0, len(rem) - QP_MAX_ITEMS)
|
|
110
|
+
if rstart:
|
|
111
|
+
frags.append(("class:qp.remote", f"\n … +{rstart} more"))
|
|
112
|
+
for r in rem[rstart:]:
|
|
113
|
+
origin = str(r.get("origin") or "") or "remote"
|
|
114
|
+
row = "\n " + one_line(f"[{origin}] {r.get('text') or ''}",
|
|
115
|
+
width - 4 if width > 0 else 0)
|
|
116
|
+
frags.append(("class:qp.remote", row))
|
|
117
|
+
start = max(0, n - QP_MAX_ITEMS)
|
|
118
|
+
if start:
|
|
119
|
+
frags.append(("class:qp.item", f"\n … +{start} more"))
|
|
120
|
+
for i in range(start, n):
|
|
121
|
+
style = "class:qp.last" if i == n - 1 else "class:qp.item"
|
|
122
|
+
row = "\n " + one_line(items[i], width - 4 if width > 0 else 0)
|
|
123
|
+
if pull is None:
|
|
124
|
+
frags.append((style, row))
|
|
125
|
+
else:
|
|
126
|
+
frags.append((style, row, _item_handler(pull, i)))
|
|
127
|
+
return frags
|
|
@@ -44,6 +44,18 @@ _ICON = {"read_file": "📖", "grep": "🔎", "glob": "🗂️", "write_file": "
|
|
|
44
44
|
_BEE = "yellow" # bee-yellow brand accent — logo / 🐝 / notes / busy dot ONLY
|
|
45
45
|
_ACCENT = "cyan" # interactive chrome ONLY — live caret / mode / panel url
|
|
46
46
|
|
|
47
|
+
# ---- welcome copy (single source of truth for the splash + its tests) ------
|
|
48
|
+
# The privacy promise is claims-disciplined: every clause is TRUE and enforced
|
|
49
|
+
# today — user data is never sold and never used to train any model (ours or
|
|
50
|
+
# third-party; documented in the DPA), and PII is masked before tool output
|
|
51
|
+
# re-enters the model (platform-enforced and covered by the platform's own
|
|
52
|
+
# test suite). Deliberately NOT said: "we collect nothing" (sessions ARE
|
|
53
|
+
# stored so Webbee can resume your work) and "guaranteed" (we say what is
|
|
54
|
+
# enforced, not what is promised). Change the wording only with a source.
|
|
55
|
+
WELCOME_PRIVACY = "🔒 Your work stays yours — never sold, never training data."
|
|
56
|
+
WELCOME_PRIVACY_DETAIL = "PII is masked before it reaches the model."
|
|
57
|
+
WELCOME_HINT = "Type a task — Webbee runs it to completion · /help · Ctrl-D to exit"
|
|
58
|
+
|
|
47
59
|
|
|
48
60
|
def _fmt_tokens(n: int) -> str:
|
|
49
61
|
"""Compact count for the live toolbar: 900 -> '900', 2_100 -> '2.1k',
|
|
@@ -122,6 +134,11 @@ class RichSink:
|
|
|
122
134
|
self._pending = ("", "")
|
|
123
135
|
self._consent = None # asyncio.Future while awaiting a reply
|
|
124
136
|
self._consent_summary = ""
|
|
137
|
+
# Cross-surface items already queued in the RUNNING kernel session
|
|
138
|
+
# (full-queue-layer K1: task_queued/task_dequeued frames). The dock's
|
|
139
|
+
# queue panel reads THIS list object every redraw — mutate in place
|
|
140
|
+
# only (append/del/clear), never rebind.
|
|
141
|
+
self.remote_pending: list = []
|
|
125
142
|
|
|
126
143
|
def _nudge(self) -> None:
|
|
127
144
|
"""After output/state changes: let the pane follow the tail + redraw."""
|
|
@@ -132,10 +149,13 @@ class RichSink:
|
|
|
132
149
|
|
|
133
150
|
# ---- welcome ------------------------------------------------------------
|
|
134
151
|
def welcome(self, account, cwd: str, surface: str) -> None:
|
|
135
|
-
"""One-time launch splash
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
152
|
+
"""One-time launch splash, trimmed to what a human actually needs:
|
|
153
|
+
the WEBBEE CODE logo (the brand), ONE identity line (who am I / plan —
|
|
154
|
+
plan status shows only when it needs attention), the privacy promise
|
|
155
|
+
(true, enforced claims only — see WELCOME_PRIVACY above), and one hint
|
|
156
|
+
line to get going. Runs BEFORE the dock starts. Clears the screen ONLY
|
|
157
|
+
in the non-pane path (the full-screen dock owns its own alternate
|
|
158
|
+
screen — clearing there would corrupt it)."""
|
|
139
159
|
if self._on_output is None:
|
|
140
160
|
self.console.clear()
|
|
141
161
|
w = self.console.width
|
|
@@ -152,29 +172,24 @@ class RichSink:
|
|
|
152
172
|
self.console.print(Text("ICNLI AI Cloud OS · Agent".center(w), style=f"bold {_ACCENT}"))
|
|
153
173
|
self.console.print(Text("· i m p e r a l . i o ·".center(w), style="dim"))
|
|
154
174
|
self.console.print()
|
|
155
|
-
rows = []
|
|
156
175
|
if account.signed_in:
|
|
157
|
-
who = account.email
|
|
158
|
-
|
|
176
|
+
who = account.email or ""
|
|
177
|
+
if account.nickname:
|
|
178
|
+
who += f" · @{account.nickname}"
|
|
159
179
|
if account.plan:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
rows.append(("Member since", account.member_since))
|
|
180
|
+
who += f" · {account.plan} plan"
|
|
181
|
+
if account.plan_status and account.plan_status != "active":
|
|
182
|
+
who += f" ({account.plan_status})"
|
|
183
|
+
label = "Signed in as "
|
|
184
|
+
pad = " " * max(0, (w - len(label) - len(who)) // 2)
|
|
185
|
+
self.console.print(Text.assemble((pad + label, "dim"), (who, "white")))
|
|
167
186
|
else:
|
|
168
|
-
|
|
169
|
-
bw = max((len(label.ljust(14) + value) for label, value in rows), default=0)
|
|
170
|
-
pad = " " * max(0, (w - bw) // 2)
|
|
171
|
-
for label, value in rows:
|
|
172
|
-
self.console.print(Text.assemble((pad + label.ljust(14), "dim"), (value, "white")))
|
|
187
|
+
self.console.print(Text("not signed in — /login".center(w), style="dim"))
|
|
173
188
|
self.console.print()
|
|
174
|
-
|
|
175
|
-
self.console.print(Text(
|
|
189
|
+
self.console.print(Text(WELCOME_PRIVACY.center(w), style="white"))
|
|
190
|
+
self.console.print(Text(WELCOME_PRIVACY_DETAIL.center(w), style="dim"))
|
|
176
191
|
self.console.print()
|
|
177
|
-
self.console.print(Text(
|
|
192
|
+
self.console.print(Text(WELCOME_HINT.center(w), style="dim"))
|
|
178
193
|
self.console.print()
|
|
179
194
|
self._nudge()
|
|
180
195
|
|
|
@@ -192,6 +207,12 @@ class RichSink:
|
|
|
192
207
|
|
|
193
208
|
def end_turn(self, final_text: str) -> None:
|
|
194
209
|
self._busy = False
|
|
210
|
+
# The kernel session's own queue only exists while a run is live —
|
|
211
|
+
# once this turn returns (complete / stopped / parked) the terminal
|
|
212
|
+
# no longer streams its dequeue frames, so any leftover remote rows
|
|
213
|
+
# would linger as phantoms. Clear them (in place — the panel holds
|
|
214
|
+
# this list object).
|
|
215
|
+
self.remote_pending.clear()
|
|
195
216
|
final_text = _clean(final_text)
|
|
196
217
|
if final_text:
|
|
197
218
|
self.console.print() # separation before the focus block
|
|
@@ -256,16 +277,36 @@ class RichSink:
|
|
|
256
277
|
self.console.print(_pad(Text(" ❯ " + _clean(text) + " ", style="bold white on grey30")))
|
|
257
278
|
self._nudge()
|
|
258
279
|
|
|
259
|
-
def
|
|
260
|
-
"""
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
self.
|
|
267
|
-
|
|
268
|
-
|
|
280
|
+
def remote_queued(self, origin: str, text: str, iid: str) -> None:
|
|
281
|
+
"""Full-queue-layer K1 (`task_queued` frame): a follow-up queued into
|
|
282
|
+
the RUNNING kernel session from another surface shows in the live
|
|
283
|
+
queue panel the instant it queues — tagged `[origin]`, "as if typed".
|
|
284
|
+
DISPLAY-ONLY: it renders above the local rows but is never pullable
|
|
285
|
+
(↑/click) — the kernel owns it; only a `task_dequeued` (or turn end)
|
|
286
|
+
removes it."""
|
|
287
|
+
self.remote_pending.append({"origin": _clean(str(origin or "")),
|
|
288
|
+
"text": _clean(str(text or "")),
|
|
289
|
+
"iid": str(iid or "")})
|
|
290
|
+
self._nudge()
|
|
291
|
+
|
|
292
|
+
def remote_dequeued(self, origin: str, iid: str) -> None:
|
|
293
|
+
"""The kernel drained (or dedup-dropped) one queued item
|
|
294
|
+
(`task_dequeued` frame): remove its panel row — by `iid` when it
|
|
295
|
+
matches, else the OLDEST row of that origin (the kernel queue is
|
|
296
|
+
FIFO, so when an iid is missing/lost the oldest same-origin row is
|
|
297
|
+
the one that just started). Nothing matched → no-op (the row was
|
|
298
|
+
already cleared or its announce was never seen)."""
|
|
299
|
+
iid = str(iid or "")
|
|
300
|
+
origin = _clean(str(origin or ""))
|
|
301
|
+
rows = self.remote_pending
|
|
302
|
+
idx = next((i for i, r in enumerate(rows) if iid and r.get("iid") == iid),
|
|
303
|
+
None)
|
|
304
|
+
if idx is None:
|
|
305
|
+
idx = next((i for i, r in enumerate(rows) if r.get("origin") == origin),
|
|
306
|
+
None)
|
|
307
|
+
if idx is None:
|
|
308
|
+
return
|
|
309
|
+
del rows[idx]
|
|
269
310
|
self._nudge()
|
|
270
311
|
|
|
271
312
|
def queued_run(self, remaining: int) -> None:
|
|
@@ -317,8 +358,10 @@ class RichSink:
|
|
|
317
358
|
|
|
318
359
|
def abort(self) -> None:
|
|
319
360
|
"""Ctrl-C mid-turn: clear busy so the toolbar drops back to idle. No
|
|
320
|
-
printing — the caller (repl.py) prints the note.
|
|
361
|
+
printing — the caller (repl.py) prints the note. Remote rows clear
|
|
362
|
+
too — the stream is gone, their dequeue frames can never arrive."""
|
|
321
363
|
self._busy = False
|
|
364
|
+
self.remote_pending.clear()
|
|
322
365
|
self._nudge()
|
|
323
366
|
|
|
324
367
|
# ---- TurnSink -------------------------------------------------------
|
|
@@ -370,6 +413,35 @@ class RichSink:
|
|
|
370
413
|
self._nudge()
|
|
371
414
|
return raw
|
|
372
415
|
|
|
416
|
+
async def ask_yes_no(self, question: str, timeout: float = 60.0) -> bool:
|
|
417
|
+
"""Terminal-LOCAL one-tap confirm for a remotely-requested privilege
|
|
418
|
+
upgrade (the autopilot safe-asymmetry): print the question and arm
|
|
419
|
+
the SAME pinned-input future the consent prompt uses (the dock's
|
|
420
|
+
Enter handler routes the raw reply here; the toolbar flips to the
|
|
421
|
+
reply state). STRICT gate — True only on an explicit local yes
|
|
422
|
+
(y/yes); n, an empty reply, no dock, a prompt error or the timeout
|
|
423
|
+
all return False, so the caller keeps the current mode. This is a
|
|
424
|
+
LOCAL policy decision, not a kernel consent, so the reply is
|
|
425
|
+
interpreted here rather than relayed (ICNLI raw-relay applies to
|
|
426
|
+
kernel consents only)."""
|
|
427
|
+
self.console.print(_pad(Text("⚠ " + _clean(question), style=f"bold {_BEE}")))
|
|
428
|
+
fut = self._arm_consent(question, question)
|
|
429
|
+
try:
|
|
430
|
+
if fut is None: # non-tty / no dock → sync reader
|
|
431
|
+
raw = self._input(" allow? [y/n] ")
|
|
432
|
+
else:
|
|
433
|
+
self._nudge()
|
|
434
|
+
raw = await asyncio.wait_for(fut, timeout)
|
|
435
|
+
except Exception: # timeout / prompt error → decline
|
|
436
|
+
raw = ""
|
|
437
|
+
finally:
|
|
438
|
+
self._consent = None
|
|
439
|
+
self._consent_summary = ""
|
|
440
|
+
raw = (raw or "").strip().lower()
|
|
441
|
+
self.console.print(Text(" ↳ " + (raw or "(no reply)"), style="dim"))
|
|
442
|
+
self._nudge()
|
|
443
|
+
return raw in ("y", "yes")
|
|
444
|
+
|
|
373
445
|
def panel_release(self, panel_url: str, summary: str) -> None:
|
|
374
446
|
body = Text.assemble(
|
|
375
447
|
(summary + "\n\n" if summary else "", "white"),
|
|
@@ -9,7 +9,7 @@ from webbee.account import login_device_flow
|
|
|
9
9
|
from webbee.boot import _git_branch, _open_dock_stderr_log, replay_thread, start_intel, start_shadow
|
|
10
10
|
from webbee.commands import CommandContext, dispatch
|
|
11
11
|
from webbee.session import AgentSession
|
|
12
|
-
from webbee.tui import next_mode
|
|
12
|
+
from webbee.tui import _MODES, next_mode
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
|
|
@@ -256,6 +256,59 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
256
256
|
_sink.foreign_turn(surface, "user", text)
|
|
257
257
|
await _run_turn(text, surface=surface, steer_iid=steer_iid)
|
|
258
258
|
|
|
259
|
+
def _poller_busy() -> bool:
|
|
260
|
+
"""The idle-steer poller's busy gate: the sink's live turn state PLUS
|
|
261
|
+
an armed local prompt (the autopilot confirm arms the same pinned-
|
|
262
|
+
input future a consent uses) -- submitting a steer turn under an
|
|
263
|
+
armed prompt could double-prompt the input, so the poller holds off
|
|
264
|
+
until it resolves. Both hooks getattr-guarded (minimal test sinks)."""
|
|
265
|
+
if bool(getattr(_sink, "is_busy", None) and _sink.is_busy()):
|
|
266
|
+
return True
|
|
267
|
+
cp = getattr(_sink, "consent_pending", None)
|
|
268
|
+
return bool(cp and cp())
|
|
269
|
+
|
|
270
|
+
def _on_mode(mode: str, surface: str) -> None:
|
|
271
|
+
"""Remote coding-mode request (TG/panel → gateway one-shot req_mode →
|
|
272
|
+
the pending-steer poll). AUTOPILOT SAFE ASYMMETRY (Valentin-chosen):
|
|
273
|
+
a downgrade or lateral move (→ default/plan) applies INSTANTLY with a
|
|
274
|
+
visible audited note; the upgrade → autopilot NEVER applies silently —
|
|
275
|
+
a terminal-local y/n confirm must approve it (the person physically
|
|
276
|
+
at the terminal is the risk bearer; a remote surface must not disarm
|
|
277
|
+
the consent prompt it is about to exploit). Unknown modes and no-ops
|
|
278
|
+
are dropped. Sync + non-blocking by contract (the poller calls it):
|
|
279
|
+
the confirm runs as its own background task."""
|
|
280
|
+
surface = surface or "remote"
|
|
281
|
+
if mode not in _MODES or mode == state["mode"]:
|
|
282
|
+
return
|
|
283
|
+
if mode != "autopilot":
|
|
284
|
+
state["mode"] = mode
|
|
285
|
+
agent.mode = mode
|
|
286
|
+
_sink.note(f"mode → {mode} [{surface}]")
|
|
287
|
+
return
|
|
288
|
+
asyncio.ensure_future(_confirm_autopilot(surface))
|
|
289
|
+
|
|
290
|
+
async def _confirm_autopilot(surface: str) -> None:
|
|
291
|
+
"""The terminal-local one-tap confirm for a remote autopilot upgrade.
|
|
292
|
+
Fail-safe in every direction: no confirm affordance, a turn/prompt
|
|
293
|
+
already live, anything but an explicit local yes, or the prompt
|
|
294
|
+
timeout all KEEP the current mode — and both outcomes leave an
|
|
295
|
+
audited note in the transcript. While the prompt is armed the steer
|
|
296
|
+
poller holds off (_poller_busy gates it), so it can never collide
|
|
297
|
+
with a real kernel consent (those only exist mid-turn, when the
|
|
298
|
+
poller does not fetch at all)."""
|
|
299
|
+
ask = getattr(_sink, "ask_yes_no", None)
|
|
300
|
+
if ask is None or _poller_busy():
|
|
301
|
+
_sink.note(f"autopilot request from {surface} not applied — mode stays {state['mode']}")
|
|
302
|
+
return
|
|
303
|
+
ok = await ask(f"{surface} asks to switch to autopilot "
|
|
304
|
+
f"(auto-approve everything) — allow? [y/n]")
|
|
305
|
+
if ok:
|
|
306
|
+
state["mode"] = "autopilot"
|
|
307
|
+
agent.mode = "autopilot"
|
|
308
|
+
_sink.note(f"mode → autopilot [{surface}] — approved at this terminal")
|
|
309
|
+
else:
|
|
310
|
+
_sink.note(f"autopilot request from {surface} declined — mode stays {state['mode']}")
|
|
311
|
+
|
|
259
312
|
def _cancel_background() -> None:
|
|
260
313
|
for _t in (watcher_task, steer_task):
|
|
261
314
|
if _t is not None:
|
|
@@ -283,14 +336,16 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
283
336
|
agent = agent_factory(cfg, token_provider, workspace, state["mode"])
|
|
284
337
|
# Liveness v2 §B: idle-steer pickup. All poll/drain logic lives in
|
|
285
338
|
# webbee.steer -- this is wiring only: the sink's live turn state
|
|
286
|
-
# gates polling, the agent's session id
|
|
287
|
-
# gateway truth,
|
|
339
|
+
# (+ an armed local prompt) gates polling, the agent's session id
|
|
340
|
+
# (once a turn has run) is the gateway truth, _steer_submit is the
|
|
341
|
+
# normal turn path, and _on_mode adopts a remote mode request
|
|
342
|
+
# (autopilot safe-asymmetry).
|
|
288
343
|
from webbee import steer as _steer
|
|
289
344
|
steer_task = asyncio.ensure_future(_steer.poll_idle_steer(
|
|
290
345
|
cfg, token_provider, workspace=workspace, marathon=not once,
|
|
291
|
-
is_busy=
|
|
346
|
+
is_busy=_poller_busy,
|
|
292
347
|
live_session_id=lambda: getattr(agent, "session_id", ""),
|
|
293
|
-
submit=_steer_submit))
|
|
348
|
+
submit=_steer_submit, on_mode=_on_mode))
|
|
294
349
|
|
|
295
350
|
if use_dock:
|
|
296
351
|
ok = False
|
|
@@ -325,8 +380,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
325
380
|
"expand": lambda i: _handle(f"/steps {i + 1}"),
|
|
326
381
|
},
|
|
327
382
|
stop_turn=lambda: agent.stop(),
|
|
328
|
-
pending=pending_queue,
|
|
329
|
-
|
|
383
|
+
pending=pending_queue, queued_run=_sink.queued_run,
|
|
384
|
+
remote_pending=getattr(_sink, "remote_pending", None),
|
|
330
385
|
)
|
|
331
386
|
finally:
|
|
332
387
|
_cancel_background()
|
|
@@ -224,6 +224,30 @@ class AgentSession:
|
|
|
224
224
|
int(frame.get("credits", 0) or 0),
|
|
225
225
|
)
|
|
226
226
|
|
|
227
|
+
elif ftype in ("task_queued", "task_dequeued"):
|
|
228
|
+
# Full-queue-layer K1: a follow-up queued into the RUNNING
|
|
229
|
+
# kernel session from another surface shows in the live
|
|
230
|
+
# queue panel the instant it queues (tagged by origin) and
|
|
231
|
+
# leaves the panel when the kernel drains it. These frames
|
|
232
|
+
# carry NO task_id (they belong to the session, not a
|
|
233
|
+
# turn), so the C7 filter never eats them; getattr-guarded
|
|
234
|
+
# like todos/queued_run — a minimal sink drops them, a
|
|
235
|
+
# render error never breaks the loop. The terminal's own
|
|
236
|
+
# follow-ups already sit in the LOCAL panel — skip
|
|
237
|
+
# terminal-origin twins.
|
|
238
|
+
_origin = str(frame.get("origin", "") or "")
|
|
239
|
+
_hook = getattr(sink, "remote_queued" if ftype == "task_queued"
|
|
240
|
+
else "remote_dequeued", None)
|
|
241
|
+
if _hook is not None and _origin != "terminal":
|
|
242
|
+
_iid = str(frame.get("steer_iid", "") or "")
|
|
243
|
+
try:
|
|
244
|
+
if ftype == "task_queued":
|
|
245
|
+
_hook(_origin, str(frame.get("text", "") or ""), _iid)
|
|
246
|
+
else:
|
|
247
|
+
_hook(_origin, _iid)
|
|
248
|
+
except Exception:
|
|
249
|
+
pass
|
|
250
|
+
|
|
227
251
|
elif ftype == "marathon_complete": # U4 — the whole GOAL is done: terminal
|
|
228
252
|
return frame.get("text", "")
|
|
229
253
|
|
|
@@ -44,6 +44,30 @@ async def derive_session_id(cfg, token_provider, workspace: str, *,
|
|
|
44
44
|
return f"{prefix}-{imperal_id}-r{await asyncio.to_thread(_repo_key)}"
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def _consume_mode(payload, on_mode) -> bool:
|
|
48
|
+
"""Hand the fetch's one-shot `requested_mode` ({mode, surface} -- GETDEL
|
|
49
|
+
on the gateway, delivered exactly once) to the injected
|
|
50
|
+
`on_mode(mode, surface)` seam. Fail-soft and guarded like every other
|
|
51
|
+
seam: a missing/malformed field, an old gateway (no key) or a seam error
|
|
52
|
+
never kills the poller -- a lost mode flip is safe (the mode simply
|
|
53
|
+
stays), a dead poller is not. Returns True when a request was handed
|
|
54
|
+
off, so the caller can yield once and let a just-spawned local confirm
|
|
55
|
+
(the autopilot upgrade prompt) arm before any queued item submits."""
|
|
56
|
+
if on_mode is None or not isinstance(payload, dict):
|
|
57
|
+
return False
|
|
58
|
+
req = payload.get("requested_mode")
|
|
59
|
+
if not isinstance(req, dict):
|
|
60
|
+
return False
|
|
61
|
+
mode = str(req.get("mode") or "").strip().lower()
|
|
62
|
+
if not mode:
|
|
63
|
+
return False
|
|
64
|
+
try:
|
|
65
|
+
on_mode(mode, str(req.get("surface") or "").strip() or "remote")
|
|
66
|
+
except Exception:
|
|
67
|
+
return False
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
47
71
|
def _cancel_absorbed() -> bool:
|
|
48
72
|
"""True when this task received a cancel that a submitted turn swallowed
|
|
49
73
|
(repl._run_turn treats CancelledError as a user interrupt). Without this
|
|
@@ -56,6 +80,7 @@ def _cancel_absorbed() -> bool:
|
|
|
56
80
|
async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
|
|
57
81
|
submit, marathon: bool = True,
|
|
58
82
|
live_session_id=lambda: "",
|
|
83
|
+
on_mode=None,
|
|
59
84
|
interval: float = _POLL_INTERVAL) -> None:
|
|
60
85
|
"""Run forever (until cancelled): every ~`interval`s of idle time, drain
|
|
61
86
|
the pending-steer queue and hand the FIRST item to
|
|
@@ -73,7 +98,14 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
|
|
|
73
98
|
is naturally paused for the turn's whole duration.
|
|
74
99
|
steer_iid = the queued item's dedup id ("" on an
|
|
75
100
|
older gateway), threaded into the turn POST so the
|
|
76
|
-
kernel's dedup ring can drop an at-least-once twin.
|
|
101
|
+
kernel's dedup ring can drop an at-least-once twin.
|
|
102
|
+
* on_mode(mode, surface) -- sync, optional; receives the payload's
|
|
103
|
+
one-shot `requested_mode` (a coding-mode flip
|
|
104
|
+
asked from TG/panel) BEFORE any fetched item is
|
|
105
|
+
submitted, so the flip governs the turn it rode
|
|
106
|
+
in with. Must never block the poller: the repl's
|
|
107
|
+
wiring applies downgrades instantly and spawns
|
|
108
|
+
the autopilot local-confirm as its own task."""
|
|
77
109
|
from webbee.thread import fetch_pending_steer
|
|
78
110
|
derived = ""
|
|
79
111
|
backlog: deque = deque()
|
|
@@ -91,7 +123,14 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
|
|
|
91
123
|
derived = await derive_session_id(
|
|
92
124
|
cfg, token_provider, workspace, marathon=marathon)
|
|
93
125
|
sid = derived
|
|
94
|
-
|
|
126
|
+
payload = await fetch_pending_steer(cfg, token_provider, sid) or {}
|
|
127
|
+
backlog.extend(payload.get("items") or [])
|
|
128
|
+
if _consume_mode(payload, on_mode):
|
|
129
|
+
# Yield ONE loop cycle so a just-spawned local confirm
|
|
130
|
+
# (autopilot upgrade) arms its prompt before the
|
|
131
|
+
# pre-submit busy re-check below -- a fetched item then
|
|
132
|
+
# defers until the person at the terminal has answered.
|
|
133
|
+
await asyncio.sleep(0)
|
|
95
134
|
if not backlog:
|
|
96
135
|
continue
|
|
97
136
|
item = backlog.popleft()
|
|
@@ -44,21 +44,27 @@ async def fetch_recent_thread(cfg, token_provider, session_id: str) -> list[dict
|
|
|
44
44
|
return (r.json() or {}).get("messages", [])
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
async def fetch_pending_steer(cfg, token_provider, session_id: str) ->
|
|
48
|
-
"""Drain this user's
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
async def fetch_pending_steer(cfg, token_provider, session_id: str) -> dict:
|
|
48
|
+
"""Drain this user's pending-steer state (idle-steer pickup, liveness v2
|
|
49
|
+
§B + full-queue-layer mode adoption) -- the /thread endpoint's sibling,
|
|
50
|
+
same auth. Returns the gateway payload verbatim:
|
|
51
|
+
* "items" -- queued remote instructions. The gateway read is
|
|
52
|
+
DESTRUCTIVE: each item is returned exactly ONCE,
|
|
53
|
+
oldest first (empty when nothing is queued or
|
|
54
|
+
remote control is disabled), so the caller owns
|
|
55
|
+
every item it receives.
|
|
56
|
+
* "requested_mode" -- one-shot remote mode request {mode, surface} or
|
|
57
|
+
null (GETDEL on the gateway -- delivered exactly
|
|
58
|
+
once; older gateways omit the key entirely).
|
|
59
|
+
Non-swallowing like fetch_recent_thread above: the poller (webbee.steer)
|
|
60
|
+
wraps each tick in its own try/except."""
|
|
55
61
|
import httpx
|
|
56
62
|
token = await token_provider()
|
|
57
63
|
async with httpx.AsyncClient(base_url=cfg.api_url, timeout=10) as c:
|
|
58
64
|
r = await c.get(f"/v1/agent/sessions/{session_id}/pending-steer",
|
|
59
65
|
headers={"Authorization": f"Bearer {token}"})
|
|
60
66
|
r.raise_for_status()
|
|
61
|
-
return
|
|
67
|
+
return r.json() or {}
|
|
62
68
|
|
|
63
69
|
|
|
64
70
|
def truncate_for_display(text, limit: int = _DISPLAY_LIMIT) -> str:
|