webbee 0.3.13__tar.gz → 0.3.15__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 (88) hide show
  1. {webbee-0.3.13 → webbee-0.3.15}/CHANGELOG.md +19 -0
  2. {webbee-0.3.13 → webbee-0.3.15}/PKG-INFO +1 -1
  3. {webbee-0.3.13 → webbee-0.3.15}/install.sh +1 -1
  4. {webbee-0.3.13 → webbee-0.3.15}/pyproject.toml +1 -1
  5. webbee-0.3.15/src/webbee/__init__.py +1 -0
  6. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/queue_panel.py +40 -14
  7. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/render.py +132 -11
  8. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/repl.py +93 -7
  9. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/session.py +25 -0
  10. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/steer.py +41 -2
  11. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/thread.py +38 -10
  12. webbee-0.3.15/src/webbee/todo_panel.py +91 -0
  13. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/tools.py +114 -4
  14. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/tui.py +129 -26
  15. {webbee-0.3.13 → webbee-0.3.15}/tests/test_packaging.py +2 -1
  16. {webbee-0.3.13 → webbee-0.3.15}/tests/test_render.py +189 -0
  17. {webbee-0.3.13 → webbee-0.3.15}/tests/test_repl.py +168 -0
  18. {webbee-0.3.13 → webbee-0.3.15}/tests/test_session.py +109 -0
  19. {webbee-0.3.13 → webbee-0.3.15}/tests/test_steer.py +117 -13
  20. {webbee-0.3.13 → webbee-0.3.15}/tests/test_thread.py +111 -5
  21. webbee-0.3.15/tests/test_todo_panel.py +87 -0
  22. {webbee-0.3.13 → webbee-0.3.15}/tests/test_tools.py +167 -10
  23. {webbee-0.3.13 → webbee-0.3.15}/tests/test_tui.py +294 -0
  24. webbee-0.3.13/src/webbee/__init__.py +0 -1
  25. {webbee-0.3.13 → webbee-0.3.15}/.github/workflows/publish.yml +0 -0
  26. {webbee-0.3.13 → webbee-0.3.15}/.gitignore +0 -0
  27. {webbee-0.3.13 → webbee-0.3.15}/LICENSE +0 -0
  28. {webbee-0.3.13 → webbee-0.3.15}/README.md +0 -0
  29. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/account.py +0 -0
  30. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/banner_art.py +0 -0
  31. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/boot.py +0 -0
  32. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/checkpoints.py +0 -0
  33. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/cli.py +0 -0
  34. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/clipboard.py +0 -0
  35. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/coding_context.py +0 -0
  36. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/commands.py +0 -0
  37. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/config.py +0 -0
  38. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/consent.py +0 -0
  39. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/details.py +0 -0
  40. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/events.py +0 -0
  41. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/frames.py +0 -0
  42. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/intel/__init__.py +0 -0
  43. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/intel/chunker.py +0 -0
  44. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/intel/embed.py +0 -0
  45. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/intel/graph.py +0 -0
  46. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/intel/indexer.py +0 -0
  47. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/intel/models.py +0 -0
  48. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/intel/query.py +0 -0
  49. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/intel/service.py +0 -0
  50. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/intel/store.py +0 -0
  51. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/intel/vectors.py +0 -0
  52. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/intel/watch.py +0 -0
  53. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/output_pane.py +0 -0
  54. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/remote.py +0 -0
  55. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/repo.py +0 -0
  56. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/sessions.py +0 -0
  57. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/stream.py +0 -0
  58. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/tokens.py +0 -0
  59. {webbee-0.3.13 → webbee-0.3.15}/src/webbee/update.py +0 -0
  60. {webbee-0.3.13 → webbee-0.3.15}/tests/__init__.py +0 -0
  61. {webbee-0.3.13 → webbee-0.3.15}/tests/test_account.py +0 -0
  62. {webbee-0.3.13 → webbee-0.3.15}/tests/test_checkpoints.py +0 -0
  63. {webbee-0.3.13 → webbee-0.3.15}/tests/test_cli.py +0 -0
  64. {webbee-0.3.13 → webbee-0.3.15}/tests/test_clipboard.py +0 -0
  65. {webbee-0.3.13 → webbee-0.3.15}/tests/test_commands.py +0 -0
  66. {webbee-0.3.13 → webbee-0.3.15}/tests/test_config.py +0 -0
  67. {webbee-0.3.13 → webbee-0.3.15}/tests/test_cpc_contract_stable.py +0 -0
  68. {webbee-0.3.13 → webbee-0.3.15}/tests/test_details.py +0 -0
  69. {webbee-0.3.13 → webbee-0.3.15}/tests/test_events.py +0 -0
  70. {webbee-0.3.13 → webbee-0.3.15}/tests/test_freeze_fix.py +0 -0
  71. {webbee-0.3.13 → webbee-0.3.15}/tests/test_intel_chunker.py +0 -0
  72. {webbee-0.3.13 → webbee-0.3.15}/tests/test_intel_embed.py +0 -0
  73. {webbee-0.3.13 → webbee-0.3.15}/tests/test_intel_graph.py +0 -0
  74. {webbee-0.3.13 → webbee-0.3.15}/tests/test_intel_indexer.py +0 -0
  75. {webbee-0.3.13 → webbee-0.3.15}/tests/test_intel_no_numpy.py +0 -0
  76. {webbee-0.3.13 → webbee-0.3.15}/tests/test_intel_query.py +0 -0
  77. {webbee-0.3.13 → webbee-0.3.15}/tests/test_intel_service.py +0 -0
  78. {webbee-0.3.13 → webbee-0.3.15}/tests/test_intel_store.py +0 -0
  79. {webbee-0.3.13 → webbee-0.3.15}/tests/test_intel_vectors.py +0 -0
  80. {webbee-0.3.13 → webbee-0.3.15}/tests/test_intel_watch.py +0 -0
  81. {webbee-0.3.13 → webbee-0.3.15}/tests/test_marathon.py +0 -0
  82. {webbee-0.3.13 → webbee-0.3.15}/tests/test_repo.py +0 -0
  83. {webbee-0.3.13 → webbee-0.3.15}/tests/test_sessions.py +0 -0
  84. {webbee-0.3.13 → webbee-0.3.15}/tests/test_stream.py +0 -0
  85. {webbee-0.3.13 → webbee-0.3.15}/tests/test_tokens.py +0 -0
  86. {webbee-0.3.13 → webbee-0.3.15}/tests/test_tui_hardening.py +0 -0
  87. {webbee-0.3.13 → webbee-0.3.15}/tests/test_update.py +0 -0
  88. {webbee-0.3.13 → webbee-0.3.15}/tests/test_version.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.15
4
+
5
+ - Type a follow-up while Webbee is working and it now flies into the current
6
+ run within seconds (not after it finishes), shown in the queue tagged by
7
+ where it came from. The task list is now a pinned panel that stays visible
8
+ and updates live.
9
+
10
+ ## 0.3.14
11
+
12
+ - See queued messages from Telegram/panel right in the terminal queue
13
+ (tagged by origin), and set the coding mode from Telegram — switching to
14
+ autopilot asks you to confirm in the terminal first.
15
+ - Reading a file now shows its size and freshness up front — line count,
16
+ when it was last modified, what it defines, and what depends on it — so
17
+ Webbee reads big files smartly and never edits a stale one; an edit to a
18
+ file that changed on disk since it was read gets a re-read nudge.
19
+ - The code-intelligence graph now installs by default (`webbee[intel]`) —
20
+ symbol awareness + repo relationships are on out of the box, not opt-in.
21
+
3
22
  ## 0.3.13
4
23
 
5
24
  - 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.15
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.15"
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.15"
@@ -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,19 @@ 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 = []
142
+ # The current coding checklist (sticky todo panel, 0.3.15) — the twin
143
+ # of remote_pending: todos() replaces its contents in place on every
144
+ # todo frame; the dock's todo panel reads THIS list object every
145
+ # redraw. Sticky: it persists across turn ends (the panel stays up)
146
+ # and clears only on /clear.
147
+ self.current_todos: list = []
148
+ self._todo_counts = (0, 0) # kernel-reported (done, total)
149
+ self._todos_dirty = False # a turn touched the list -> end_turn records it
137
150
 
138
151
  def _nudge(self) -> None:
139
152
  """After output/state changes: let the pane follow the tail + redraw."""
@@ -202,6 +215,20 @@ class RichSink:
202
215
 
203
216
  def end_turn(self, final_text: str) -> None:
204
217
  self._busy = False
218
+ # The kernel session's own queue only exists while a run is live —
219
+ # once this turn returns (complete / stopped / parked) the terminal
220
+ # no longer streams its dequeue frames, so any leftover remote rows
221
+ # would linger as phantoms. Clear them (in place — the panel holds
222
+ # this list object).
223
+ self.remote_pending.clear()
224
+ # Sticky-todo scrollback record (0.3.15): in the dock the live panel
225
+ # replaced the inline re-renders, so print the FINAL checklist state
226
+ # ONCE per turn that touched it — the transcript keeps the history
227
+ # while the panel itself persists (sticky) into idle.
228
+ if self._on_output is not None and self._todos_dirty and self.current_todos:
229
+ done, total = self._todo_counts
230
+ self._todos_inline(self.current_todos, total, done)
231
+ self._todos_dirty = False
205
232
  final_text = _clean(final_text)
206
233
  if final_text:
207
234
  self.console.print() # separation before the focus block
@@ -226,15 +253,41 @@ class RichSink:
226
253
  self._nudge()
227
254
 
228
255
  def todos(self, items: list, total: int, done: int) -> None:
229
- """Claude-Code-style checklist for the coding TODO scratchpad. The
230
- kernel republishes the FULL list on every todo_write, so each update
231
- re-renders the whole current plan inline the latest complete list
232
- always sits in the scrollback (deliberately NOT a pinned sticky panel:
233
- inline re-render can't fight the dock layout). Order preserved, one
234
- line per item: ✓ completed (dim, struck) · in progress (bold, so
235
- "what's happening now" pops) · pending (muted). Defensive: malformed
236
- items are skipped, unknown statuses render as pending, bad counts
237
- degrade to a bare header — never raises."""
256
+ """Checklist state for the coding TODO scratchpad. The kernel
257
+ republishes the FULL list on every todo_write; each update replaces
258
+ `current_todos` IN PLACE (the dock's sticky todo panel holds this
259
+ list object and re-reads it every redraw 0.3.15, no more inline
260
+ scroll-away in the dock; end_turn prints the final checklist ONCE as
261
+ the scrollback record). Headless / non-dock sinks (no on_output pane)
262
+ keep today's full inline render. Defensive both ways: malformed items
263
+ are skipped, bad counts degrade, never raises."""
264
+ rows = []
265
+ for item in (items if isinstance(items, (list, tuple)) else ()):
266
+ if not isinstance(item, dict):
267
+ continue # malformed entry — skip, never raise
268
+ content = _clean(item.get("content", "")).strip()
269
+ if not content:
270
+ continue
271
+ rows.append({"content": content,
272
+ "status": str(item.get("status", "") or "")})
273
+ self.current_todos[:] = rows # in place — the panel holds this list
274
+ try:
275
+ self._todo_counts = (int(done), int(total))
276
+ except (TypeError, ValueError):
277
+ self._todo_counts = (sum(1 for r in rows if r["status"] == "completed"),
278
+ len(rows))
279
+ self._todos_dirty = True
280
+ if self._on_output is not None: # dock: the sticky panel renders it live
281
+ self._nudge()
282
+ return
283
+ self._todos_inline(items, total, done)
284
+
285
+ def _todos_inline(self, items, total, done) -> None:
286
+ """The Claude-Code-style INLINE checklist render (the pre-0.3.15
287
+ behavior, verbatim): one line per item — ✓ completed (dim, struck) ·
288
+ ▶ in progress (bold, so "what's happening now" pops) · ○ pending
289
+ (muted), order preserved. Used by the headless/non-dock path on every
290
+ todo frame and by end_turn's one-shot scrollback record in the dock."""
238
291
  try:
239
292
  head = f"📋 Todos ({int(done)}/{int(total)})"
240
293
  except (TypeError, ValueError):
@@ -266,6 +319,38 @@ class RichSink:
266
319
  self.console.print(_pad(Text(" ❯ " + _clean(text) + " ", style="bold white on grey30")))
267
320
  self._nudge()
268
321
 
322
+ def remote_queued(self, origin: str, text: str, iid: str) -> None:
323
+ """Full-queue-layer K1 (`task_queued` frame): a follow-up queued into
324
+ the RUNNING kernel session from another surface shows in the live
325
+ queue panel the instant it queues — tagged `[origin]`, "as if typed".
326
+ DISPLAY-ONLY: it renders above the local rows but is never pullable
327
+ (↑/click) — the kernel owns it; only a `task_dequeued` (or turn end)
328
+ removes it."""
329
+ self.remote_pending.append({"origin": _clean(str(origin or "")),
330
+ "text": _clean(str(text or "")),
331
+ "iid": str(iid or "")})
332
+ self._nudge()
333
+
334
+ def remote_dequeued(self, origin: str, iid: str) -> None:
335
+ """The kernel drained (or dedup-dropped) one queued item
336
+ (`task_dequeued` frame): remove its panel row — by `iid` when it
337
+ matches, else the OLDEST row of that origin (the kernel queue is
338
+ FIFO, so when an iid is missing/lost the oldest same-origin row is
339
+ the one that just started). Nothing matched → no-op (the row was
340
+ already cleared or its announce was never seen)."""
341
+ iid = str(iid or "")
342
+ origin = _clean(str(origin or ""))
343
+ rows = self.remote_pending
344
+ idx = next((i for i, r in enumerate(rows) if iid and r.get("iid") == iid),
345
+ None)
346
+ if idx is None:
347
+ idx = next((i for i, r in enumerate(rows) if r.get("origin") == origin),
348
+ None)
349
+ if idx is None:
350
+ return
351
+ del rows[idx]
352
+ self._nudge()
353
+
269
354
  def queued_run(self, remaining: int) -> None:
270
355
  """The tiny lifecycle marker printed right before a drained queued
271
356
  line starts: `▶ running queued message` (+ how many still wait) — a
@@ -303,7 +388,10 @@ class RichSink:
303
388
  self._nudge()
304
389
 
305
390
  def clear(self) -> None:
306
- """/clear: wipe the pane/screen + reset the session counters."""
391
+ """/clear: wipe the pane/screen + reset the session counters. The
392
+ sticky todo panel resets with it (in place — the panel holds the
393
+ list object); a mid-run abort deliberately does NOT (the last known
394
+ plan state is still true and the panel is always-on)."""
307
395
  self.console.clear()
308
396
  self.tokens = 0
309
397
  self.credits = 0
@@ -311,12 +399,16 @@ class RichSink:
311
399
  self.session_credits = 0
312
400
  self._tools = 0
313
401
  self._current = ""
402
+ self.current_todos.clear()
403
+ self._todos_dirty = False
314
404
  self._nudge()
315
405
 
316
406
  def abort(self) -> None:
317
407
  """Ctrl-C mid-turn: clear busy so the toolbar drops back to idle. No
318
- printing — the caller (repl.py) prints the note."""
408
+ printing — the caller (repl.py) prints the note. Remote rows clear
409
+ too — the stream is gone, their dequeue frames can never arrive."""
319
410
  self._busy = False
411
+ self.remote_pending.clear()
320
412
  self._nudge()
321
413
 
322
414
  # ---- TurnSink -------------------------------------------------------
@@ -368,6 +460,35 @@ class RichSink:
368
460
  self._nudge()
369
461
  return raw
370
462
 
463
+ async def ask_yes_no(self, question: str, timeout: float = 60.0) -> bool:
464
+ """Terminal-LOCAL one-tap confirm for a remotely-requested privilege
465
+ upgrade (the autopilot safe-asymmetry): print the question and arm
466
+ the SAME pinned-input future the consent prompt uses (the dock's
467
+ Enter handler routes the raw reply here; the toolbar flips to the
468
+ reply state). STRICT gate — True only on an explicit local yes
469
+ (y/yes); n, an empty reply, no dock, a prompt error or the timeout
470
+ all return False, so the caller keeps the current mode. This is a
471
+ LOCAL policy decision, not a kernel consent, so the reply is
472
+ interpreted here rather than relayed (ICNLI raw-relay applies to
473
+ kernel consents only)."""
474
+ self.console.print(_pad(Text("⚠ " + _clean(question), style=f"bold {_BEE}")))
475
+ fut = self._arm_consent(question, question)
476
+ try:
477
+ if fut is None: # non-tty / no dock → sync reader
478
+ raw = self._input(" allow? [y/n] ")
479
+ else:
480
+ self._nudge()
481
+ raw = await asyncio.wait_for(fut, timeout)
482
+ except Exception: # timeout / prompt error → decline
483
+ raw = ""
484
+ finally:
485
+ self._consent = None
486
+ self._consent_summary = ""
487
+ raw = (raw or "").strip().lower()
488
+ self.console.print(Text(" ↳ " + (raw or "(no reply)"), style="dim"))
489
+ self._nudge()
490
+ return raw in ("y", "yes")
491
+
371
492
  def panel_release(self, panel_url: str, summary: str) -> None:
372
493
  body = Text.assemble(
373
494
  (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,
@@ -54,6 +54,30 @@ async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
54
54
  return text
55
55
 
56
56
 
57
+ async def _inject_via_gateway(cfg, token_provider, agent, sink,
58
+ text: str, steer_iid: str) -> bool:
59
+ """The gateway leg of the dock's Enter-while-busy fly-in (mid-turn inject,
60
+ 0.3.15): POST the line straight into the agent's LIVE running session so
61
+ the marathon absorbs it at the next brain step (seconds), instead of
62
+ holding it client-side until the turn ends. On ok the line is kernel-owned
63
+ — the ❯ echo records it as sent and the kernel's task_queued[terminal]
64
+ echo drives the panel row. False on ANY failure (no live session yet,
65
+ network, auth, gateway refusal) — the dock then falls back to today's
66
+ local type-ahead queue (tui._inject_or_queue), carrying the same iid so
67
+ the kernel ring dedups a twin. Module-level so tests drive it directly."""
68
+ sid = getattr(agent, "session_id", "")
69
+ if not sid:
70
+ return False
71
+ try:
72
+ from webbee.thread import inject_to_session
73
+ ok = await inject_to_session(cfg, token_provider, sid, text, steer_iid)
74
+ except Exception:
75
+ return False
76
+ if ok:
77
+ sink.user_echo(text) # the transcript records the message as sent
78
+ return ok
79
+
80
+
57
81
  async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None, read_line=input,
58
82
  agent_factory=None, auth=None, account_fetcher=None,
59
83
  sessions_client=None, intel_factory=None, shadow_factory=None) -> None:
@@ -220,9 +244,12 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
220
244
  _sink.note(res.message)
221
245
  return "continue"
222
246
 
223
- # A task for the agent.
247
+ # A task for the agent. A drained type-ahead line minted for a
248
+ # failed mid-turn inject still carries its steer_iid (tui.QueuedLine)
249
+ # -- thread it so the kernel's dedup ring drops the twin if the
250
+ # inject actually landed server-side (a plain typed line has none).
224
251
  _sink.user_echo(line)
225
- await _run_turn(line)
252
+ await _run_turn(line, steer_iid=getattr(line, "iid", ""))
226
253
  return "continue"
227
254
 
228
255
  async def _run_turn(line: str, surface: str = "", steer_iid: str = "") -> None:
@@ -256,6 +283,59 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
256
283
  _sink.foreign_turn(surface, "user", text)
257
284
  await _run_turn(text, surface=surface, steer_iid=steer_iid)
258
285
 
286
+ def _poller_busy() -> bool:
287
+ """The idle-steer poller's busy gate: the sink's live turn state PLUS
288
+ an armed local prompt (the autopilot confirm arms the same pinned-
289
+ input future a consent uses) -- submitting a steer turn under an
290
+ armed prompt could double-prompt the input, so the poller holds off
291
+ until it resolves. Both hooks getattr-guarded (minimal test sinks)."""
292
+ if bool(getattr(_sink, "is_busy", None) and _sink.is_busy()):
293
+ return True
294
+ cp = getattr(_sink, "consent_pending", None)
295
+ return bool(cp and cp())
296
+
297
+ def _on_mode(mode: str, surface: str) -> None:
298
+ """Remote coding-mode request (TG/panel → gateway one-shot req_mode →
299
+ the pending-steer poll). AUTOPILOT SAFE ASYMMETRY (Valentin-chosen):
300
+ a downgrade or lateral move (→ default/plan) applies INSTANTLY with a
301
+ visible audited note; the upgrade → autopilot NEVER applies silently —
302
+ a terminal-local y/n confirm must approve it (the person physically
303
+ at the terminal is the risk bearer; a remote surface must not disarm
304
+ the consent prompt it is about to exploit). Unknown modes and no-ops
305
+ are dropped. Sync + non-blocking by contract (the poller calls it):
306
+ the confirm runs as its own background task."""
307
+ surface = surface or "remote"
308
+ if mode not in _MODES or mode == state["mode"]:
309
+ return
310
+ if mode != "autopilot":
311
+ state["mode"] = mode
312
+ agent.mode = mode
313
+ _sink.note(f"mode → {mode} [{surface}]")
314
+ return
315
+ asyncio.ensure_future(_confirm_autopilot(surface))
316
+
317
+ async def _confirm_autopilot(surface: str) -> None:
318
+ """The terminal-local one-tap confirm for a remote autopilot upgrade.
319
+ Fail-safe in every direction: no confirm affordance, a turn/prompt
320
+ already live, anything but an explicit local yes, or the prompt
321
+ timeout all KEEP the current mode — and both outcomes leave an
322
+ audited note in the transcript. While the prompt is armed the steer
323
+ poller holds off (_poller_busy gates it), so it can never collide
324
+ with a real kernel consent (those only exist mid-turn, when the
325
+ poller does not fetch at all)."""
326
+ ask = getattr(_sink, "ask_yes_no", None)
327
+ if ask is None or _poller_busy():
328
+ _sink.note(f"autopilot request from {surface} not applied — mode stays {state['mode']}")
329
+ return
330
+ ok = await ask(f"{surface} asks to switch to autopilot "
331
+ f"(auto-approve everything) — allow? [y/n]")
332
+ if ok:
333
+ state["mode"] = "autopilot"
334
+ agent.mode = "autopilot"
335
+ _sink.note(f"mode → autopilot [{surface}] — approved at this terminal")
336
+ else:
337
+ _sink.note(f"autopilot request from {surface} declined — mode stays {state['mode']}")
338
+
259
339
  def _cancel_background() -> None:
260
340
  for _t in (watcher_task, steer_task):
261
341
  if _t is not None:
@@ -283,14 +363,16 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
283
363
  agent = agent_factory(cfg, token_provider, workspace, state["mode"])
284
364
  # Liveness v2 §B: idle-steer pickup. All poll/drain logic lives in
285
365
  # 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.
366
+ # (+ an armed local prompt) gates polling, the agent's session id
367
+ # (once a turn has run) is the gateway truth, _steer_submit is the
368
+ # normal turn path, and _on_mode adopts a remote mode request
369
+ # (autopilot safe-asymmetry).
288
370
  from webbee import steer as _steer
289
371
  steer_task = asyncio.ensure_future(_steer.poll_idle_steer(
290
372
  cfg, token_provider, workspace=workspace, marathon=not once,
291
- is_busy=lambda: bool(getattr(_sink, "is_busy", None) and _sink.is_busy()),
373
+ is_busy=_poller_busy,
292
374
  live_session_id=lambda: getattr(agent, "session_id", ""),
293
- submit=_steer_submit))
375
+ submit=_steer_submit, on_mode=_on_mode))
294
376
 
295
377
  if use_dock:
296
378
  ok = False
@@ -326,6 +408,10 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
326
408
  },
327
409
  stop_turn=lambda: agent.stop(),
328
410
  pending=pending_queue, queued_run=_sink.queued_run,
411
+ remote_pending=getattr(_sink, "remote_pending", None),
412
+ todos=getattr(_sink, "current_todos", None),
413
+ inject=lambda text, iid: _inject_via_gateway(
414
+ cfg, token_provider, agent, _sink, text, iid),
329
415
  )
330
416
  finally:
331
417
  _cancel_background()
@@ -224,6 +224,31 @@ 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 shows in the live queue panel the instant
230
+ # it queues (tagged by origin) and leaves the panel when
231
+ # the kernel drains it. These frames carry NO task_id
232
+ # (they belong to the session, not a turn), so the C7
233
+ # filter never eats them; getattr-guarded like todos/
234
+ # queued_run — a minimal sink drops them, a render error
235
+ # never breaks the loop. Terminal-origin rows render TOO
236
+ # (mid-turn inject, 0.3.15): an injected line never sits
237
+ # in the LOCAL panel — the kernel's echo is its only row,
238
+ # and task_dequeued clears it when the turn absorbs it.
239
+ _origin = str(frame.get("origin", "") or "")
240
+ _hook = getattr(sink, "remote_queued" if ftype == "task_queued"
241
+ else "remote_dequeued", None)
242
+ if _hook is not None:
243
+ _iid = str(frame.get("steer_iid", "") or "")
244
+ try:
245
+ if ftype == "task_queued":
246
+ _hook(_origin, str(frame.get("text", "") or ""), _iid)
247
+ else:
248
+ _hook(_origin, _iid)
249
+ except Exception:
250
+ pass
251
+
227
252
  elif ftype == "marathon_complete": # U4 — the whole GOAL is done: terminal
228
253
  return frame.get("text", "")
229
254
 
@@ -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()