webbee 0.3.13__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.13 → webbee-0.3.14}/CHANGELOG.md +12 -0
- {webbee-0.3.13 → webbee-0.3.14}/PKG-INFO +1 -1
- {webbee-0.3.13 → webbee-0.3.14}/install.sh +1 -1
- {webbee-0.3.13 → webbee-0.3.14}/pyproject.toml +1 -1
- webbee-0.3.14/src/webbee/__init__.py +1 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/queue_panel.py +40 -14
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/render.py +75 -1
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/repl.py +61 -5
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/session.py +24 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/steer.py +41 -2
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/thread.py +15 -9
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/tools.py +114 -4
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/tui.py +17 -6
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_render.py +107 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_repl.py +95 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_session.py +102 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_steer.py +117 -13
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_thread.py +40 -5
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_tools.py +167 -10
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_tui.py +72 -0
- webbee-0.3.13/src/webbee/__init__.py +0 -1
- {webbee-0.3.13 → webbee-0.3.14}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/.gitignore +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/LICENSE +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/README.md +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/account.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/boot.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/cli.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/commands.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/config.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/consent.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/details.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/events.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/frames.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/remote.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/repo.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/sessions.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/stream.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/tokens.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/src/webbee/update.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/__init__.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_account.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_cli.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_clipboard.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_commands.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_config.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_details.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_events.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_query.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_service.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_store.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_marathon.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_packaging.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_repo.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_sessions.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_stream.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_tokens.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_update.py +0 -0
- {webbee-0.3.13 → webbee-0.3.14}/tests/test_version.py +0 -0
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## 0.3.13
|
|
4
16
|
|
|
5
17
|
- The message queue is now a live panel above the input — you see exactly
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.14"
|
|
@@ -24,16 +24,23 @@ def one_line(text: str, width: int) -> str:
|
|
|
24
24
|
return t
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def queue_height(pending) -> int:
|
|
27
|
+
def queue_height(pending, remote=None) -> int:
|
|
28
28
|
"""PURE. Rows the panel needs: 1 header + one per SHOWN item + one
|
|
29
|
-
`… +K more` row when
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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."""
|
|
33
34
|
n = len(pending)
|
|
34
|
-
|
|
35
|
+
r = len(remote or ())
|
|
36
|
+
if not n and not r:
|
|
35
37
|
return 0
|
|
36
|
-
|
|
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
|
|
37
44
|
|
|
38
45
|
|
|
39
46
|
def pull_item(pending, buf, index: int) -> bool:
|
|
@@ -67,7 +74,7 @@ def _item_handler(pull, index: int):
|
|
|
67
74
|
return _h
|
|
68
75
|
|
|
69
76
|
|
|
70
|
-
def queue_fragments(pending, pull=None, width: int = 0):
|
|
77
|
+
def queue_fragments(pending, pull=None, width: int = 0, remote=None):
|
|
71
78
|
"""PURE builder: the panel as prompt_toolkit formatted text, re-invoked
|
|
72
79
|
every redraw (same live mechanics as the toolbar) so every queue
|
|
73
80
|
add/edit/drain shows at once. Layout, top→bottom = drain order (FIFO —
|
|
@@ -75,19 +82,38 @@ def queue_fragments(pending, pull=None, width: int = 0):
|
|
|
75
82
|
sitting right above the input):
|
|
76
83
|
|
|
77
84
|
⋯ queued (N) · ↑ edit last · click to edit
|
|
85
|
+
[telegram] remote item ← remote rows ABOVE local (qp.remote)
|
|
78
86
|
… +K more ← only when N > QP_MAX_ITEMS (the OLDEST hide)
|
|
79
87
|
older item ← muted (qp.item)
|
|
80
88
|
newest item ← accent (qp.last)
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
queue
|
|
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)."""
|
|
85
101
|
items = list(pending)
|
|
102
|
+
rem = [r for r in (remote or ()) if isinstance(r, dict)]
|
|
86
103
|
n = len(items)
|
|
87
|
-
if not n:
|
|
104
|
+
if not n and not rem:
|
|
88
105
|
return []
|
|
89
|
-
frags = [("class:qp.header", f" ⋯ queued ({n})")
|
|
90
|
-
|
|
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))
|
|
91
117
|
start = max(0, n - QP_MAX_ITEMS)
|
|
92
118
|
if start:
|
|
93
119
|
frags.append(("class:qp.item", f"\n … +{start} more"))
|
|
@@ -134,6 +134,11 @@ class RichSink:
|
|
|
134
134
|
self._pending = ("", "")
|
|
135
135
|
self._consent = None # asyncio.Future while awaiting a reply
|
|
136
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 = []
|
|
137
142
|
|
|
138
143
|
def _nudge(self) -> None:
|
|
139
144
|
"""After output/state changes: let the pane follow the tail + redraw."""
|
|
@@ -202,6 +207,12 @@ class RichSink:
|
|
|
202
207
|
|
|
203
208
|
def end_turn(self, final_text: str) -> None:
|
|
204
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()
|
|
205
216
|
final_text = _clean(final_text)
|
|
206
217
|
if final_text:
|
|
207
218
|
self.console.print() # separation before the focus block
|
|
@@ -266,6 +277,38 @@ class RichSink:
|
|
|
266
277
|
self.console.print(_pad(Text(" ❯ " + _clean(text) + " ", style="bold white on grey30")))
|
|
267
278
|
self._nudge()
|
|
268
279
|
|
|
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]
|
|
310
|
+
self._nudge()
|
|
311
|
+
|
|
269
312
|
def queued_run(self, remaining: int) -> None:
|
|
270
313
|
"""The tiny lifecycle marker printed right before a drained queued
|
|
271
314
|
line starts: `▶ running queued message` (+ how many still wait) — a
|
|
@@ -315,8 +358,10 @@ class RichSink:
|
|
|
315
358
|
|
|
316
359
|
def abort(self) -> None:
|
|
317
360
|
"""Ctrl-C mid-turn: clear busy so the toolbar drops back to idle. No
|
|
318
|
-
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."""
|
|
319
363
|
self._busy = False
|
|
364
|
+
self.remote_pending.clear()
|
|
320
365
|
self._nudge()
|
|
321
366
|
|
|
322
367
|
# ---- TurnSink -------------------------------------------------------
|
|
@@ -368,6 +413,35 @@ class RichSink:
|
|
|
368
413
|
self._nudge()
|
|
369
414
|
return raw
|
|
370
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
|
+
|
|
371
445
|
def panel_release(self, panel_url: str, summary: str) -> None:
|
|
372
446
|
body = Text.assemble(
|
|
373
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
|
|
@@ -326,6 +381,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
326
381
|
},
|
|
327
382
|
stop_turn=lambda: agent.stop(),
|
|
328
383
|
pending=pending_queue, queued_run=_sink.queued_run,
|
|
384
|
+
remote_pending=getattr(_sink, "remote_pending", None),
|
|
329
385
|
)
|
|
330
386
|
finally:
|
|
331
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:
|