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.
Files changed (86) hide show
  1. {webbee-0.3.13 → webbee-0.3.14}/CHANGELOG.md +12 -0
  2. {webbee-0.3.13 → webbee-0.3.14}/PKG-INFO +1 -1
  3. {webbee-0.3.13 → webbee-0.3.14}/install.sh +1 -1
  4. {webbee-0.3.13 → webbee-0.3.14}/pyproject.toml +1 -1
  5. webbee-0.3.14/src/webbee/__init__.py +1 -0
  6. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/queue_panel.py +40 -14
  7. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/render.py +75 -1
  8. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/repl.py +61 -5
  9. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/session.py +24 -0
  10. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/steer.py +41 -2
  11. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/thread.py +15 -9
  12. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/tools.py +114 -4
  13. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/tui.py +17 -6
  14. {webbee-0.3.13 → webbee-0.3.14}/tests/test_render.py +107 -0
  15. {webbee-0.3.13 → webbee-0.3.14}/tests/test_repl.py +95 -0
  16. {webbee-0.3.13 → webbee-0.3.14}/tests/test_session.py +102 -0
  17. {webbee-0.3.13 → webbee-0.3.14}/tests/test_steer.py +117 -13
  18. {webbee-0.3.13 → webbee-0.3.14}/tests/test_thread.py +40 -5
  19. {webbee-0.3.13 → webbee-0.3.14}/tests/test_tools.py +167 -10
  20. {webbee-0.3.13 → webbee-0.3.14}/tests/test_tui.py +72 -0
  21. webbee-0.3.13/src/webbee/__init__.py +0 -1
  22. {webbee-0.3.13 → webbee-0.3.14}/.github/workflows/publish.yml +0 -0
  23. {webbee-0.3.13 → webbee-0.3.14}/.gitignore +0 -0
  24. {webbee-0.3.13 → webbee-0.3.14}/LICENSE +0 -0
  25. {webbee-0.3.13 → webbee-0.3.14}/README.md +0 -0
  26. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/account.py +0 -0
  27. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/banner_art.py +0 -0
  28. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/boot.py +0 -0
  29. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/checkpoints.py +0 -0
  30. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/cli.py +0 -0
  31. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/clipboard.py +0 -0
  32. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/coding_context.py +0 -0
  33. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/commands.py +0 -0
  34. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/config.py +0 -0
  35. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/consent.py +0 -0
  36. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/details.py +0 -0
  37. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/events.py +0 -0
  38. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/frames.py +0 -0
  39. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/__init__.py +0 -0
  40. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/chunker.py +0 -0
  41. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/embed.py +0 -0
  42. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/graph.py +0 -0
  43. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/indexer.py +0 -0
  44. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/models.py +0 -0
  45. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/query.py +0 -0
  46. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/service.py +0 -0
  47. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/store.py +0 -0
  48. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/vectors.py +0 -0
  49. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/intel/watch.py +0 -0
  50. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/output_pane.py +0 -0
  51. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/remote.py +0 -0
  52. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/repo.py +0 -0
  53. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/sessions.py +0 -0
  54. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/stream.py +0 -0
  55. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/tokens.py +0 -0
  56. {webbee-0.3.13 → webbee-0.3.14}/src/webbee/update.py +0 -0
  57. {webbee-0.3.13 → webbee-0.3.14}/tests/__init__.py +0 -0
  58. {webbee-0.3.13 → webbee-0.3.14}/tests/test_account.py +0 -0
  59. {webbee-0.3.13 → webbee-0.3.14}/tests/test_checkpoints.py +0 -0
  60. {webbee-0.3.13 → webbee-0.3.14}/tests/test_cli.py +0 -0
  61. {webbee-0.3.13 → webbee-0.3.14}/tests/test_clipboard.py +0 -0
  62. {webbee-0.3.13 → webbee-0.3.14}/tests/test_commands.py +0 -0
  63. {webbee-0.3.13 → webbee-0.3.14}/tests/test_config.py +0 -0
  64. {webbee-0.3.13 → webbee-0.3.14}/tests/test_cpc_contract_stable.py +0 -0
  65. {webbee-0.3.13 → webbee-0.3.14}/tests/test_details.py +0 -0
  66. {webbee-0.3.13 → webbee-0.3.14}/tests/test_events.py +0 -0
  67. {webbee-0.3.13 → webbee-0.3.14}/tests/test_freeze_fix.py +0 -0
  68. {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_chunker.py +0 -0
  69. {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_embed.py +0 -0
  70. {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_graph.py +0 -0
  71. {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_indexer.py +0 -0
  72. {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_no_numpy.py +0 -0
  73. {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_query.py +0 -0
  74. {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_service.py +0 -0
  75. {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_store.py +0 -0
  76. {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_vectors.py +0 -0
  77. {webbee-0.3.13 → webbee-0.3.14}/tests/test_intel_watch.py +0 -0
  78. {webbee-0.3.13 → webbee-0.3.14}/tests/test_marathon.py +0 -0
  79. {webbee-0.3.13 → webbee-0.3.14}/tests/test_packaging.py +0 -0
  80. {webbee-0.3.13 → webbee-0.3.14}/tests/test_repo.py +0 -0
  81. {webbee-0.3.13 → webbee-0.3.14}/tests/test_sessions.py +0 -0
  82. {webbee-0.3.13 → webbee-0.3.14}/tests/test_stream.py +0 -0
  83. {webbee-0.3.13 → webbee-0.3.14}/tests/test_tokens.py +0 -0
  84. {webbee-0.3.13 → webbee-0.3.14}/tests/test_tui_hardening.py +0 -0
  85. {webbee-0.3.13 → webbee-0.3.14}/tests/test_update.py +0 -0
  86. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.13
3
+ Version: 0.3.14
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -10,7 +10,7 @@ if ! command -v uv >/dev/null 2>&1; then
10
10
  fi
11
11
 
12
12
  echo "Installing webbee…"
13
- uv tool install webbee
13
+ uv tool install "webbee[intel,intel-embed]"
14
14
 
15
15
  echo ""
16
16
  echo "✅ webbee installed. Start it with: webbee"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.3.13"
3
+ version = "0.3.14"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.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 the queue is deeper than QP_MAX_ITEMS. 0 when empty
30
- (the ConditionalContainer hides the panel then anyway). The cap keeps the
31
- output pane dominant on small terminals; the toolbar's `⋯N queued`
32
- segment stays the truth-teller for the full depth."""
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
- if not n:
35
+ r = len(remote or ())
36
+ if not n and not r:
35
37
  return 0
36
- return 1 + min(n, QP_MAX_ITEMS) + (1 if n > QP_MAX_ITEMS else 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
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
- When `pull` is given each item row is a 3-tuple fragment carrying a
83
- mouse handler that pulls exactly that item (see _item_handler). Empty
84
- queue [] (the panel is hidden)."""
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
- ("class:qp.item", " · ↑ edit last · click to edit")]
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 (once a turn has run) is the
287
- # gateway truth, and _steer_submit is the normal turn path.
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=lambda: bool(getattr(_sink, "is_busy", None) and _sink.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
- backlog.extend(await fetch_pending_steer(cfg, token_provider, sid))
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) -> list[dict]:
48
- """Drain this user's queued remote instructions (idle-steer pickup,
49
- liveness v2 §B) -- the /thread endpoint's sibling, same auth. The gateway
50
- read is DESTRUCTIVE: each queued item is returned exactly ONCE, oldest
51
- first (empty when nothing is queued or remote control is disabled), so
52
- the caller owns every item it receives. Non-swallowing like
53
- fetch_recent_thread above: the poller (webbee.steer) wraps each tick in
54
- its own try/except."""
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 (r.json() or {}).get("items", [])
67
+ return r.json() or {}
62
68
 
63
69
 
64
70
  def truncate_for_display(text, limit: int = _DISPLAY_LIMIT) -> str: