webbee 0.3.12__tar.gz → 0.3.14__tar.gz

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