webbee 0.3.22__tar.gz → 0.3.24__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 (108) hide show
  1. {webbee-0.3.22 → webbee-0.3.24}/CHANGELOG.md +25 -0
  2. {webbee-0.3.22 → webbee-0.3.24}/PKG-INFO +1 -1
  3. {webbee-0.3.22 → webbee-0.3.24}/pyproject.toml +1 -1
  4. webbee-0.3.24/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/repl.py +80 -2
  6. webbee-0.3.24/src/webbee/session.py +501 -0
  7. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/slots.py +4 -0
  8. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/steer.py +38 -0
  9. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/tabs.py +38 -23
  10. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/thread.py +8 -0
  11. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/tui.py +116 -49
  12. {webbee-0.3.22 → webbee-0.3.24}/tests/test_home.py +46 -0
  13. {webbee-0.3.22 → webbee-0.3.24}/tests/test_repl.py +56 -0
  14. {webbee-0.3.22 → webbee-0.3.24}/tests/test_session.py +125 -0
  15. {webbee-0.3.22 → webbee-0.3.24}/tests/test_steer.py +110 -0
  16. {webbee-0.3.22 → webbee-0.3.24}/tests/test_tabs.py +84 -28
  17. {webbee-0.3.22 → webbee-0.3.24}/tests/test_tui.py +236 -23
  18. webbee-0.3.22/src/webbee/__init__.py +0 -1
  19. webbee-0.3.22/src/webbee/session.py +0 -453
  20. {webbee-0.3.22 → webbee-0.3.24}/.github/workflows/publish.yml +0 -0
  21. {webbee-0.3.22 → webbee-0.3.24}/.gitignore +0 -0
  22. {webbee-0.3.22 → webbee-0.3.24}/LICENSE +0 -0
  23. {webbee-0.3.22 → webbee-0.3.24}/README.md +0 -0
  24. {webbee-0.3.22 → webbee-0.3.24}/install.sh +0 -0
  25. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/account.py +0 -0
  26. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/active_sessions.py +0 -0
  27. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/banner_art.py +0 -0
  28. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/boot.py +0 -0
  29. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/checkpoints.py +0 -0
  30. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/cli.py +0 -0
  31. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/clipboard.py +0 -0
  32. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/coding_context.py +0 -0
  33. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/commands.py +0 -0
  34. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/config.py +0 -0
  35. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/consent.py +0 -0
  36. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/details.py +0 -0
  37. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/events.py +0 -0
  38. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/frames.py +0 -0
  39. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/home.py +0 -0
  40. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/http.py +0 -0
  41. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/intel/__init__.py +0 -0
  42. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/intel/chunker.py +0 -0
  43. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/intel/embed.py +0 -0
  44. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/intel/graph.py +0 -0
  45. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/intel/indexer.py +0 -0
  46. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/intel/models.py +0 -0
  47. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/intel/query.py +0 -0
  48. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/intel/service.py +0 -0
  49. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/intel/store.py +0 -0
  50. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/intel/vectors.py +0 -0
  51. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/intel/watch.py +0 -0
  52. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/mode_store.py +0 -0
  53. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/output_pane.py +0 -0
  54. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/queue_panel.py +0 -0
  55. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/reflow.py +0 -0
  56. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/remote.py +0 -0
  57. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/render.py +0 -0
  58. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/repo.py +0 -0
  59. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/selection.py +0 -0
  60. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/sessions.py +0 -0
  61. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/sizing.py +0 -0
  62. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/stream.py +0 -0
  63. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/todo_panel.py +0 -0
  64. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/tokens.py +0 -0
  65. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/tools.py +0 -0
  66. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/update.py +0 -0
  67. {webbee-0.3.22 → webbee-0.3.24}/src/webbee/worktrees.py +0 -0
  68. {webbee-0.3.22 → webbee-0.3.24}/tests/__init__.py +0 -0
  69. {webbee-0.3.22 → webbee-0.3.24}/tests/conftest.py +0 -0
  70. {webbee-0.3.22 → webbee-0.3.24}/tests/test_account.py +0 -0
  71. {webbee-0.3.22 → webbee-0.3.24}/tests/test_active_sessions.py +0 -0
  72. {webbee-0.3.22 → webbee-0.3.24}/tests/test_checkpoints.py +0 -0
  73. {webbee-0.3.22 → webbee-0.3.24}/tests/test_cli.py +0 -0
  74. {webbee-0.3.22 → webbee-0.3.24}/tests/test_clipboard.py +0 -0
  75. {webbee-0.3.22 → webbee-0.3.24}/tests/test_commands.py +0 -0
  76. {webbee-0.3.22 → webbee-0.3.24}/tests/test_config.py +0 -0
  77. {webbee-0.3.22 → webbee-0.3.24}/tests/test_cpc_contract_stable.py +0 -0
  78. {webbee-0.3.22 → webbee-0.3.24}/tests/test_details.py +0 -0
  79. {webbee-0.3.22 → webbee-0.3.24}/tests/test_events.py +0 -0
  80. {webbee-0.3.22 → webbee-0.3.24}/tests/test_freeze_fix.py +0 -0
  81. {webbee-0.3.22 → webbee-0.3.24}/tests/test_intel_chunker.py +0 -0
  82. {webbee-0.3.22 → webbee-0.3.24}/tests/test_intel_embed.py +0 -0
  83. {webbee-0.3.22 → webbee-0.3.24}/tests/test_intel_graph.py +0 -0
  84. {webbee-0.3.22 → webbee-0.3.24}/tests/test_intel_indexer.py +0 -0
  85. {webbee-0.3.22 → webbee-0.3.24}/tests/test_intel_no_numpy.py +0 -0
  86. {webbee-0.3.22 → webbee-0.3.24}/tests/test_intel_query.py +0 -0
  87. {webbee-0.3.22 → webbee-0.3.24}/tests/test_intel_service.py +0 -0
  88. {webbee-0.3.22 → webbee-0.3.24}/tests/test_intel_store.py +0 -0
  89. {webbee-0.3.22 → webbee-0.3.24}/tests/test_intel_vectors.py +0 -0
  90. {webbee-0.3.22 → webbee-0.3.24}/tests/test_intel_watch.py +0 -0
  91. {webbee-0.3.22 → webbee-0.3.24}/tests/test_marathon.py +0 -0
  92. {webbee-0.3.22 → webbee-0.3.24}/tests/test_mode_store.py +0 -0
  93. {webbee-0.3.22 → webbee-0.3.24}/tests/test_packaging.py +0 -0
  94. {webbee-0.3.22 → webbee-0.3.24}/tests/test_reflow.py +0 -0
  95. {webbee-0.3.22 → webbee-0.3.24}/tests/test_render.py +0 -0
  96. {webbee-0.3.22 → webbee-0.3.24}/tests/test_repo.py +0 -0
  97. {webbee-0.3.22 → webbee-0.3.24}/tests/test_sessions.py +0 -0
  98. {webbee-0.3.22 → webbee-0.3.24}/tests/test_sizing.py +0 -0
  99. {webbee-0.3.22 → webbee-0.3.24}/tests/test_slots.py +0 -0
  100. {webbee-0.3.22 → webbee-0.3.24}/tests/test_stream.py +0 -0
  101. {webbee-0.3.22 → webbee-0.3.24}/tests/test_thread.py +0 -0
  102. {webbee-0.3.22 → webbee-0.3.24}/tests/test_todo_panel.py +0 -0
  103. {webbee-0.3.22 → webbee-0.3.24}/tests/test_tokens.py +0 -0
  104. {webbee-0.3.22 → webbee-0.3.24}/tests/test_tools.py +0 -0
  105. {webbee-0.3.22 → webbee-0.3.24}/tests/test_tui_hardening.py +0 -0
  106. {webbee-0.3.22 → webbee-0.3.24}/tests/test_update.py +0 -0
  107. {webbee-0.3.22 → webbee-0.3.24}/tests/test_version.py +0 -0
  108. {webbee-0.3.22 → webbee-0.3.24}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.24
4
+
5
+ The tab bar got a real design: the active tab is a solid highlighted chip,
6
+ tabs are clearly separated, and spacing is uniform — plus a message typed
7
+ on Home no longer shows up twice in the new tab.
8
+
9
+ - Tabs now render as padded chips with uniform spacing, a clear dim
10
+ separator between every pair, and the active tab as a solid
11
+ bee-yellow block — unmistakable at a glance, no more squinting to find
12
+ which tab you're on.
13
+ - Typing a task on Home and landing in the new session tab no longer
14
+ double-prints your first message in that tab's transcript.
15
+ - Each tab now keeps its own input draft — switch away and back, your
16
+ unsent text is right where you left it.
17
+
18
+ ## 0.3.23
19
+
20
+ If a task on your session is waiting for your terminal — sent from the panel
21
+ or Telegram while the terminal was idle — the terminal now picks it up
22
+ within seconds instead of the task stalling.
23
+
24
+ - An idle terminal now notices when a running task on its own session is
25
+ waiting for a tool approval or result, and reconnects to it automatically
26
+ — no more silent stalls that eventually get marked "unresponsive".
27
+
3
28
  ## 0.3.22
4
29
 
5
30
  Open the same project in two tabs and they truly run in parallel — each tab
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.22
3
+ Version: 0.3.24
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.3.22"
3
+ version = "0.3.24"
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.24"
@@ -573,11 +573,19 @@ async def _home_input(text: str, *, slots: SlotManager, cfg, token_provider, mod
573
573
  ui_hooks.get("switch", slots.switch)(idx)
574
574
  if spawn_poller is not None:
575
575
  spawn_poller(new_slot)
576
- new_slot.sink.user_echo(text)
577
576
  start_turn_in = ui_hooks.get("start_turn_in")
578
577
  if start_turn_in is not None:
578
+ # 0.3.24 fix: the dock's start-turn seam threads through on_line ->
579
+ # `_handle`, which ALREADY echoes the typed line itself for every
580
+ # non-command line (the SAME canonical echo every other typed line
581
+ # in the dock gets) -- echoing here too doubled it up in the new
582
+ # tab's transcript (Valentin, live on 0.3.22/0.3.23).
579
583
  start_turn_in(new_slot, text)
580
584
  else:
585
+ # No dock: `run_turn` (repl's `_run_turn`) never echoes on its own
586
+ # (it only runs the agent turn), so this IS the one echo the typed
587
+ # line gets.
588
+ new_slot.sink.user_echo(text)
581
589
  await run_turn(new_slot, text)
582
590
 
583
591
 
@@ -977,6 +985,72 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
977
985
  slot.sink.foreign_turn(surface, "user", text)
978
986
  await _run_turn_on(slot, text, surface=surface, steer_iid=steer_iid)
979
987
 
988
+ async def _attach_turn_on(slot: SessionSlot, attach_info: dict) -> None:
989
+ """poll_idle_steer's `attach_turn` seam (attach-on-poll, URGENT
990
+ live-user-pain fix): a kernel-side marathon turn woken by a panel/
991
+ Telegram message dispatched a local tool_request/confirm_request
992
+ while THIS terminal sat idle-but-online -- nothing attached the
993
+ stream, so the dispatch just burned tool_wait (5-65min) before the
994
+ kernel parked it "unresponsive". `attach_info` is the gateway's
995
+ {task_id, last_id, kind} (GET /pending-steer's additive `attach`
996
+ field); `slot.agent.attach()` opens the SSE with NO start POST at
997
+ all -- doing so alone fires client_connected server-side, which is
998
+ what makes the kernel re-dispatch the pending request. `marathon=not
999
+ once` (the SAME flag `_spawn_slot_poller` already threads into
1000
+ poll_idle_steer/derive_session_id for THIS slot's poller) so a
1001
+ --once slot's attach derives its "coding-"-prefixed id, never the
1002
+ "marathon-" one -- a mismatched prefix would derive a DIFFERENT,
1003
+ wrong session id than the one whose `attach` field this actually is.
1004
+
1005
+ Runs through the SAME slot-explicit start seam `_home_input` uses
1006
+ (`ui_hooks["start_attach_in"]`, tui's own `_start_attach_in`) so
1007
+ `slot.turn["task"]` is genuinely set under a dock -- Esc/Ctrl-C can
1008
+ cancel it locally exactly like a typed turn, and begin_turn/
1009
+ end_turn/busy-gate/queue-drain semantics are identical to any other
1010
+ turn (mirrors `_run_turn_on`'s own shape). The fallback loop (no
1011
+ dock, no ui_hooks entry) just awaits the coroutine directly -- no
1012
+ Esc/Ctrl-C key handling exists there to satisfy. Either way this
1013
+ BLOCKS for the whole turn: poll_idle_steer's `attach_turn` contract
1014
+ requires pausing polling for its duration, same discipline as
1015
+ `submit`/`_steer_submit_on` above."""
1016
+ async def _drive() -> None:
1017
+ _sink = slot.sink
1018
+ _sink.begin_turn()
1019
+ _note = getattr(_sink, "note", None)
1020
+ if _note is not None:
1021
+ _note("⚡ attaching — a running task on this session needs this terminal")
1022
+ task_id = str(attach_info.get("task_id") or "")
1023
+ start_id = str(attach_info.get("last_id") or "0-0")
1024
+ try:
1025
+ text = await slot.agent.attach(_sink, task_id=task_id, start_id=start_id,
1026
+ marathon=not once)
1027
+ except (KeyboardInterrupt, asyncio.CancelledError):
1028
+ _sink.abort()
1029
+ _sink.note("Interrupted.")
1030
+ _sink.end_turn("") # clear busy (poller starvation guard)
1031
+ return
1032
+ except Exception as e: # network/auth/etc — never crash the poller
1033
+ _mark = getattr(_sink, "mark_turn_failed", None)
1034
+ if _mark is not None:
1035
+ _mark()
1036
+ if type(e).__name__ in ("StreamAuthError", "NotLoggedInError"):
1037
+ _sink.note("Session expired or access revoked — run /login to sign in again.")
1038
+ else:
1039
+ _sink.note(f"Error: {type(e).__name__}: {e}")
1040
+ _sink.end_turn("")
1041
+ if slot.pending:
1042
+ _sink.note(f"⏸ queue held: {len(slot.pending)} queued message(s) wait "
1043
+ "— ↑ pulls the next into the input, /queue clear drops them")
1044
+ return
1045
+ _sink.end_turn(text)
1046
+
1047
+ coro = _drive()
1048
+ start_attach_in = ui_hooks.get("start_attach_in")
1049
+ if start_attach_in is not None:
1050
+ await start_attach_in(slot, coro)
1051
+ else:
1052
+ await coro
1053
+
980
1054
  async def _stop_active_turn() -> None:
981
1055
  """tui.run_session's `stop_turn` leg (Esc/Ctrl-C) -- resolves the
982
1056
  ACTIVE slot's agent AT CALL TIME (W4a Task 3: the injected callable
@@ -1006,7 +1080,10 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1006
1080
  shared `first_session_slot`: `is_busy`/`mode_getter` read its OWN
1007
1081
  sink/turn/mode, `live_session_id` its OWN agent, `submit` renders the
1008
1082
  remote line into its OWN pane and runs the turn through
1009
- `_run_turn_on(slot, ...)`. Staggered (item 3): each poller's FIRST
1083
+ `_run_turn_on(slot, ...)`, and `attach_turn` (attach-on-poll) runs
1084
+ `_attach_turn_on(slot, ...)` against this SAME slot when the
1085
+ gateway's drain reports an unanswered request already dispatched on
1086
+ this session. Staggered (item 3): each poller's FIRST
1010
1087
  tick carries a small incrementing offset (0, 1, 2, 0, 1, 2... seconds
1011
1088
  -- `poll_idle_steer`'s own `initial_delay`) so several tabs opened
1012
1089
  back-to-back don't all hit the gateway in the same instant. Appended
@@ -1025,6 +1102,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1025
1102
  slot, text, surface, steer_iid),
1026
1103
  on_mode=lambda mode, surface: _on_mode(slot, mode, surface),
1027
1104
  mode_getter=lambda: slot.mode,
1105
+ attach_turn=lambda attach_info: _attach_turn_on(slot, attach_info),
1028
1106
  client=shared_client, slot_id=slot.slot_id, initial_delay=offset))
1029
1107
  slot.bg_tasks.append(task)
1030
1108
 
@@ -0,0 +1,501 @@
1
+ import asyncio
2
+ import time
3
+ from collections import OrderedDict, deque
4
+
5
+ from webbee.coding_context import build_coding_context, detect_verify_cmd
6
+ from webbee.consent import _retire, race_consent
7
+ from webbee.frames import (
8
+ _MARATHON_FACT_TYPES,
9
+ _first_time,
10
+ _is_foreign_frame,
11
+ _origin_tag,
12
+ _progress_text,
13
+ _summary,
14
+ handle_action_frame,
15
+ handle_step_finished,
16
+ handle_step_started,
17
+ handle_tool_request,
18
+ marathon_note,
19
+ render_foreign_frame,
20
+ render_todo_frame,
21
+ )
22
+
23
+
24
+ # W1 Task 10 (own-task watchdog): a test seam for the deduped-twin-hang clock
25
+ # below -- monkeypatched as `webbee.session._monotonic` so a test can drive
26
+ # elapsed-time without a real sleep.
27
+ _monotonic = time.monotonic
28
+
29
+ # Deduped-twin hang (W1 front-3b): a turn's task_id can be ring-dropped
30
+ # kernel-side as an at-least-once twin, so the shared stream carries ONLY
31
+ # frames tagged with OTHER task_ids while this turn's own task_id never
32
+ # appears. Left alone the dock spins "working" forever. Past this many
33
+ # seconds of foreign-only traffic with zero own frames, end the turn honestly.
34
+ _FOREIGN_ONLY_DEADLINE_S = 90.0
35
+
36
+
37
+ _SEEN_KEEP = 64 # kernel re-dispatch only ever targets the CURRENT pending
38
+ # req_id -- a small recency window is enough; unbounded full
39
+ # results (file bodies, bash output) were the many-hour-
40
+ # marathon RAM leak (W1 front-1).
41
+
42
+
43
+ def _remember(seen: OrderedDict, rid, out) -> None:
44
+ """LRU-bounded store for `seen` -- the run() dedup cache. A (re-)store
45
+ always counts as the most-recent touch; the least-recently-touched entry
46
+ is evicted once the store grows past _SEEN_KEEP."""
47
+ seen[rid] = out
48
+ seen.move_to_end(rid)
49
+ while len(seen) > _SEEN_KEEP:
50
+ seen.popitem(last=False)
51
+
52
+
53
+ def _is_transient_status(status: int) -> bool:
54
+ return status >= 500 or status in (408, 429)
55
+
56
+
57
+ def _transient_exceptions():
58
+ # Lazy import (module-level `import httpx` would move httpx's cost to CLI
59
+ # boot -- session.py is imported by repl.py at module top, and the rest of
60
+ # this module already keeps httpx as a run()-local, boot-time-optimization
61
+ # import). `except <call expression>` is valid Python -- the call just
62
+ # has to happen on every except test, which is cheap (import is memoized).
63
+ import httpx
64
+ return (httpx.HTTPError, OSError)
65
+
66
+
67
+ async def _transient_retry(send, *, attempts: int = 5, base: float = 1.0, cap: float = 8.0):
68
+ """Bounded transient-retry for gateway WRITE calls (turn-start POST): a
69
+ 502 during a deploy must not kill the turn. Verdict/normal statuses return
70
+ immediately; TRANSPORT errors (httpx.HTTPError/OSError) and 5xx/408/429
71
+ retry with capped backoff -- anything else (a bug in the caller) is not a
72
+ transient condition and must surface immediately, not be retried 5x.
73
+ The LAST failure is returned/raised for the caller's raise_for_status."""
74
+ backoff = base
75
+ last_exc = None
76
+ last_resp = None
77
+ n = max(1, attempts)
78
+ for i in range(n):
79
+ try:
80
+ resp = await send()
81
+ if not _is_transient_status(resp.status_code):
82
+ return resp
83
+ last_resp, last_exc = resp, None
84
+ except _transient_exceptions() as e:
85
+ last_exc, last_resp = e, None
86
+ if i < n - 1: # never sleep after the FINAL attempt
87
+ await asyncio.sleep(backoff)
88
+ backoff = min(backoff * 2, cap)
89
+ if last_exc is not None:
90
+ raise last_exc
91
+ return last_resp
92
+
93
+
94
+ class AgentSession:
95
+ """Client-side driver for one coding turn against the Imperal cloud.
96
+ The brain runs server-side; this is the hands — it streams kernel-
97
+ pre-approved tool_request frames over SSE, runs each tool locally, relays
98
+ confirm_request replies RAW for the brain to interpret, drives the sink
99
+ for live UI, and posts results back until a final frame arrives.
100
+
101
+ P1: one POST per turn (server reloads the shared webbee-terminal thread,
102
+ so context carries across turns). Persistent signal-based sessions are P3."""
103
+
104
+ def __init__(self, cfg, token_provider, workspace_root: str, mode: str = "default", intel=None,
105
+ shadow=None, slot_id: str = "") -> None:
106
+ self.cfg = cfg
107
+ self.token_provider = token_provider
108
+ self.workspace_root = workspace_root
109
+ self.mode = mode
110
+ self.session_id: str = ""
111
+ self.steps: deque = deque(maxlen=200)
112
+ self._task_id: str = ""
113
+ self._intel = intel # IntelService, or None (base install / boot failure)
114
+ self._shadow = shadow # ShadowGit, or None (git unavailable / boot failure)
115
+ # W4b T5: "" (tab-1 / the fallback loop's only slot) keeps every turn's
116
+ # POST body byte-identical to before this feature existed -- a later
117
+ # tab's own short slot_id rides the body as `slot`, and the gateway
118
+ # mints an `-s{slot}`-suffixed session id from it.
119
+ self.slot_id = slot_id
120
+
121
+ async def _headers(self) -> dict:
122
+ token = await self.token_provider()
123
+ return {"Authorization": f"Bearer {token}"}
124
+
125
+ async def run(self, task: str, sink, *, marathon: bool = False, goal: str = "",
126
+ surface: str = "", steer_iid: str = "") -> str:
127
+ import httpx
128
+ from uuid import uuid4
129
+
130
+ from webbee.tools import LocalToolExecutor
131
+ from imperal_mcp.client import ImperalClient
132
+
133
+ # FIX1 (W1 final review): every turn-start POST needs a stable dedup
134
+ # key. A typed turn mints its own (steer_iid arrives empty); a steer
135
+ # pickup keeps the queue entry's OWN id. Either way, _transient_retry
136
+ # below re-sends this SAME closure -- so a retry after an ambiguous
137
+ # failure (timeout / edge-504 whose first attempt may have already
138
+ # landed) carries the IDENTICAL key, and the kernel's steer-iid ring
139
+ # drops the twin instead of executing the instruction twice.
140
+ steer_iid = steer_iid or uuid4().hex
141
+
142
+ # Offload to a worker thread — build_coding_context runs sync
143
+ # subprocess.run(git status, timeout=10) + os.walk; inline on the dock's
144
+ # asyncio loop it froze the whole UI at every turn start.
145
+ coding_context = await asyncio.to_thread(build_coding_context, self.workspace_root, self._intel)
146
+ if marathon:
147
+ # verify_cmd is CLIENT-detected here and carried in coding_context —
148
+ # the trusted proof-of-done the kernel runs (never brain-authored).
149
+ root = coding_context.get("repo_root") or coding_context.get("cwd") or self.workspace_root
150
+ verify_cmd = await asyncio.to_thread(detect_verify_cmd, root)
151
+ coding_context = {**coding_context, "verify_cmd": verify_cmd}
152
+ imperal_id = await ImperalClient(self.cfg, self.token_provider).whoami()
153
+ executor = LocalToolExecutor(self.workspace_root, indexer=self._intel,
154
+ shadow=self._shadow)
155
+
156
+ body = {"user_id": imperal_id, "task": task, "coding_context": coding_context}
157
+ if surface:
158
+ # Liveness v2 §B: an idle-steer pickup carries the queued item's
159
+ # origin surface so the kernel adopts it start-path (provenance +
160
+ # [surface] tags). Additive-only -- a typed turn's body is
161
+ # byte-identical to before.
162
+ body["surface"] = surface
163
+ # steer-iid-dedup: ALWAYS present now (FIX1 above) -- W1 turn-start
164
+ # retries need the ring dedup, so a typed turn's minted id rides the
165
+ # body exactly like a pickup's own id. No longer additive-only for
166
+ # this key (surface/marathon/goal above still are).
167
+ body["steer_iid"] = steer_iid
168
+ if self.slot_id:
169
+ # W4b T5: slot-suffixed session ids -- the gateway mints
170
+ # {prefix}-{imperal_id}-r{repo_key}-s{slot} when a slot rides the
171
+ # body. Additive-only: "" (tab-1 / fallback's only slot) keeps
172
+ # today's body byte-identical, no "slot" key at all.
173
+ body["slot"] = self.slot_id
174
+ if marathon:
175
+ body["marathon"] = True
176
+ body["goal"] = goal
177
+
178
+ headers = await self._headers()
179
+ async with httpx.AsyncClient(base_url=self.cfg.api_url, timeout=60) as client:
180
+ resp = await _transient_retry(lambda: client.post(
181
+ "/v1/agent/sessions", json=body, headers=headers))
182
+ resp.raise_for_status()
183
+ _sess = resp.json()
184
+ session_id = _sess["session_id"]
185
+ start_id = _sess.get("last_id", "0-0")
186
+ task_id = _sess.get("task_id", "")
187
+ return await self._serve_stream(sink, client, session_id, start_id, task_id,
188
+ executor, marathon=marathon)
189
+
190
+ async def attach(self, sink, *, task_id: str, start_id: str, marathon: bool = True) -> str:
191
+ """Attach-on-poll (idle terminal picks up a kernel-dispatched task):
192
+ NO start POST. A marathon turn woken by a panel/Telegram message
193
+ while this terminal sat idle can dispatch a local tool_request/
194
+ confirm_request that nothing attaches -- the gateway's
195
+ `GET .../pending-steer` response flags this via its additive
196
+ `attach` field (set only when the drain found no items AND the
197
+ session's stream tail holds an unanswered request) and hands the
198
+ client `task_id`/`last_id` to resume from. This session id is
199
+ DERIVED the same way `webbee.steer.derive_session_id` derives it
200
+ for polling in the first place (this AgentSession already knows its
201
+ own workspace + slot_id, so the derivation is reused verbatim, not
202
+ reinvented) -- simply opening the SSE stream against it fires
203
+ `client_connected` server-side, which makes the kernel re-dispatch
204
+ the pending request onto the stream. From there this runs through
205
+ the EXACT SAME frame-loop `run()` uses (`_serve_stream`): tool
206
+ execution, consent racing, dedup, and the final/marathon_paused
207
+ exits all behave identically to a normal turn -- including
208
+ begin_turn/end_turn semantics, busy gates, and Esc/stop (both
209
+ driven by the caller around this call, same as any other turn)."""
210
+ import httpx
211
+
212
+ from webbee.steer import derive_session_id
213
+ from webbee.tools import LocalToolExecutor
214
+
215
+ session_id = await derive_session_id(
216
+ self.cfg, self.token_provider, self.workspace_root,
217
+ marathon=marathon, slot_id=self.slot_id)
218
+ executor = LocalToolExecutor(self.workspace_root, indexer=self._intel,
219
+ shadow=self._shadow)
220
+ async with httpx.AsyncClient(base_url=self.cfg.api_url, timeout=60) as client:
221
+ return await self._serve_stream(sink, client, session_id, start_id, task_id,
222
+ executor, marathon=marathon)
223
+
224
+ async def _serve_stream(self, sink, client, session_id: str, start_id: str,
225
+ task_id: str, executor, *, marathon: bool = False) -> str:
226
+ """The frame-loop phase of ONE coding turn -- factored out of `run()`
227
+ (behavior-preserving split) so `attach()` (no start POST at all --
228
+ see there) can drive the identical loop against a session id it
229
+ derived itself instead of one a fresh POST just minted. Sets
230
+ `self.session_id`/`self._task_id` itself (rather than trusting the
231
+ caller already did) so every frame-loop reference to `self._task_id`
232
+ below (the C7 foreign-frame filter, the consent race) sees the
233
+ RIGHT turn's id regardless of which caller reached this point."""
234
+ self.session_id = session_id
235
+ self._task_id = task_id
236
+ self.steps = deque(maxlen=200)
237
+
238
+ seen: OrderedDict = OrderedDict() # req_id -> already-posted result
239
+ # (at-least-once dedup); LRU-64 via
240
+ # _remember -- see _SEEN_KEEP.
241
+ # Slice-5 T9: id-sets shared across BOTH vocabularies for EXT tools
242
+ # (the kernel reuses the SAME tc["id"] as step_id there), so a step
243
+ # announced by one vocabulary is never re-announced by its
244
+ # dual-emitted twin. step_labels carries a v2 step_started's label
245
+ # forward to its later step_finished (which carries no app_id/tool
246
+ # of its own — facts-only). local_ids tracks LOCAL-tool v2 step_ids,
247
+ # which use a disjoint id space from the tool_request round trip
248
+ # (see webbee.frames' module docstring) and are a pure no-op.
249
+ started: set = set()
250
+ finished: set = set()
251
+ step_labels: dict = {}
252
+ local_ids: set = set()
253
+ from webbee.stream import stream_frames
254
+ _fr = getattr(self.token_provider, "force_refresh", None)
255
+ _rc = getattr(sink, "reconnecting", None)
256
+ stream = stream_frames(client, session_id, self._headers, start_id=start_id,
257
+ force_refresh=_fr, on_retry=_rc)
258
+ # Liveness A: explicit __anext__ pulls (not `async for`) so a
259
+ # pending local consent can RACE the stream. Between iterations at
260
+ # most ONE of carry_task/carry_frame is set — a consent race hands
261
+ # ownership of its pulled-ahead pull back to this loop: a pending
262
+ # task when consent won, an already-pulled frame when the stream
263
+ # won. Everything else is byte-identical to the old async-for.
264
+ carry_task = None # a still-pending __anext__ task (consent won)
265
+ carry_frame = None # an already-pulled frame (the stream won)
266
+ _t0 = _monotonic() # W1 Task 10: own-task watchdog start
267
+ _own_frames = False # -> True the instant ANY non-foreign frame lands
268
+ try:
269
+ while True:
270
+ if carry_frame is not None:
271
+ frame, carry_frame = carry_frame, None
272
+ else:
273
+ if carry_task is not None:
274
+ pull, carry_task = carry_task, None
275
+ else:
276
+ pull = asyncio.ensure_future(stream.__anext__())
277
+ try:
278
+ frame = await pull
279
+ except StopAsyncIteration:
280
+ break
281
+ ftype = frame.get("type")
282
+
283
+ # A frame from a DIFFERENT turn on the shared persistent stream
284
+ # (task_id absent on legacy kernels -> treated as own). C7 safety:
285
+ # foreign actionable frames are NEVER executed/consented and NEVER
286
+ # end this turn -- but instead of vanishing they (and any origin-
287
+ # stamped cross-surface display frame, e.g. a Telegram-steered
288
+ # turn's progress) now render ONE tagged, display-only line.
289
+ if _is_foreign_frame(frame, self._task_id):
290
+ render_foreign_frame(frame, sink)
291
+ if (not _own_frames and self._task_id
292
+ and _monotonic() - _t0 > _FOREIGN_ONLY_DEADLINE_S):
293
+ # Deduped-twin hang (W1 front-3b): the kernel ring
294
+ # dropped this turn's task as a twin -- its task_id
295
+ # will never appear on the stream. Foreign traffic
296
+ # flowing + zero own frames is the signature; end
297
+ # honestly instead of spinning "working" forever.
298
+ _note = getattr(sink, "note", None)
299
+ if _note is not None:
300
+ _note("⚠ this message produced no work of its own "
301
+ "(likely a duplicate the kernel dropped) — done waiting")
302
+ return ""
303
+ continue
304
+ _own_frames = True
305
+
306
+ # Live steer topology: a Telegram/panel-steered turn keeps THIS
307
+ # client's task_id (the terminal stays the sole executor) with
308
+ # `origin` stamped -- tag the text renders below; everything
309
+ # else (execution, dedup, consent, accounting) is unchanged.
310
+ _tag = _origin_tag(frame)
311
+
312
+ if ftype == "tool_request":
313
+ rid = frame.get("req_id")
314
+ sid = str(rid or "")
315
+ if rid in seen:
316
+ seen.move_to_end(rid)
317
+ out = seen[rid]
318
+ else:
319
+ # UI rendering is guarded so it can never block the
320
+ # result POST below (an unposted result hangs the kernel
321
+ # dispatch and freezes the dock).
322
+ if _first_time(sid, started):
323
+ try:
324
+ sink.tool_start(_tag + frame.get("tool", ""), frame.get("args", {}))
325
+ except Exception:
326
+ pass
327
+ out = await asyncio.to_thread(handle_tool_request, frame, executor)
328
+ res = out["result"]
329
+ if _first_time(sid, finished):
330
+ try:
331
+ sink.tool_result(_tag + frame.get("tool", ""), bool(res.get("ok")), _summary(res))
332
+ self.steps.append({"step_id": sid,
333
+ "label": frame.get("tool", ""),
334
+ "ok": bool(res.get("ok"))})
335
+ except Exception:
336
+ pass
337
+ _remember(seen, rid, out)
338
+ await self._post_result(client, session_id, out)
339
+
340
+ elif ftype == "confirm_request":
341
+ rid = frame.get("req_id")
342
+ if rid in seen:
343
+ seen.move_to_end(rid)
344
+ await self._post_result(client, session_id, seen[rid])
345
+ else:
346
+ if self.mode == "plan":
347
+ sink.plan_blocked(frame.get("tool", ""))
348
+ # Liveness A: the local prompt races the stream so a
349
+ # consent answered from ANOTHER surface (Telegram
350
+ # relay) unfreezes this terminal instead of leaving
351
+ # the dock stuck on `approve? y/n`.
352
+ race = await race_consent(frame, sink, stream,
353
+ mode=self.mode, task_id=self._task_id)
354
+ carry_task, carry_frame = race.carry_task, race.carry_frame
355
+ if race.stream_ended:
356
+ break
357
+ if race.out is not None:
358
+ _remember(seen, rid, race.out)
359
+ await self._post_result(client, session_id, race.out)
360
+
361
+ elif ftype == "panel_release_required":
362
+ sink.panel_release(frame.get("panel_url", ""), frame.get("summary", ""))
363
+
364
+ elif ftype == "action": # R2 — ext-tool call (server-side) surfaced in the feed
365
+ handle_action_frame(frame, sink, started, finished, self.steps)
366
+
367
+ elif ftype == "step_started": # v2 (Slice-5 T8 dual-emit)
368
+ handle_step_started(frame, sink, started, step_labels, local_ids)
369
+
370
+ elif ftype == "step_finished": # v2 (Slice-5 T8 dual-emit)
371
+ handle_step_finished(frame, sink, finished, step_labels, self.steps, local_ids)
372
+
373
+ elif ftype == "thinking": # system-driven reasoning -> the 💭 block
374
+ _text = _progress_text(frame)
375
+ (getattr(sink, "thinking", None) or sink.progress)(_tag + _text if _text else "")
376
+
377
+ elif ftype == "progress": # P2 — dual-reads llm_text (v2) / text (legacy)
378
+ _text = _progress_text(frame)
379
+ sink.progress(_tag + _text if _text else "")
380
+
381
+ elif ftype == "usage": # P2 — cumulative tokens + credits (Slice C; raw $ stays server-side)
382
+ sink.usage(
383
+ int(frame.get("tokens", 0) or 0),
384
+ int(frame.get("credits", 0) or 0),
385
+ )
386
+
387
+ elif ftype in ("task_queued", "task_dequeued"):
388
+ # Full-queue-layer K1: a follow-up queued into the RUNNING
389
+ # kernel session shows in the live queue panel the instant
390
+ # it queues (tagged by origin) and leaves the panel when
391
+ # the kernel drains it. These frames carry NO task_id
392
+ # (they belong to the session, not a turn), so the C7
393
+ # filter never eats them; getattr-guarded like todos/
394
+ # queued_run — a minimal sink drops them, a render error
395
+ # never breaks the loop. Terminal-origin rows render TOO
396
+ # (mid-turn inject, 0.3.15): an injected line never sits
397
+ # in the LOCAL panel — the kernel's echo is its only row,
398
+ # and task_dequeued clears it when the turn absorbs it.
399
+ _origin = str(frame.get("origin", "") or "")
400
+ _hook = getattr(sink, "remote_queued" if ftype == "task_queued"
401
+ else "remote_dequeued", None)
402
+ if _hook is not None:
403
+ _iid = str(frame.get("steer_iid", "") or "")
404
+ try:
405
+ if ftype == "task_queued":
406
+ _hook(_origin, str(frame.get("text", "") or ""), _iid)
407
+ else:
408
+ _hook(_origin, _iid)
409
+ except Exception:
410
+ pass
411
+
412
+ elif ftype == "marathon_complete": # U4 — the whole GOAL is done: terminal
413
+ return frame.get("text", "")
414
+
415
+ elif ftype in _MARATHON_FACT_TYPES: # U4 — marathon plan/milestone/pause/todo
416
+ # Facts-only. A `todo` fact carries the FULL list -> the
417
+ # dedicated full-checklist render (falls back to the old
418
+ # one-line note on a minimal sink). The other facts render
419
+ # ONE human-readable line. Guarded: a minimal sink (no
420
+ # `note`) simply drops the fact rather than crashing the
421
+ # turn (the stream reader already tolerates unknown frame
422
+ # types by ignoring them).
423
+ if ftype == "todo":
424
+ render_todo_frame(frame, sink)
425
+ else:
426
+ _note = getattr(sink, "note", None)
427
+ if _note is not None:
428
+ _note(marathon_note(frame))
429
+ if ftype == "marathon_paused":
430
+ # Parked (out-of-credits / consent / runaway) -> end the
431
+ # turn so the dock leaves "working"; the note shows why, and
432
+ # the run resumes on the user's next reply. The kernel's task
433
+ # queue stays alive server-side while parked -- tell the sink
434
+ # via marathon_parked() so end_turn keeps the queue-panel's
435
+ # remote rows (tagged parked) instead of wiping them (W1
436
+ # front-3b: an empty panel over a non-empty kernel queue lies).
437
+ # getattr-guarded like every other sink hook here: a minimal
438
+ # sink drops it, a crashing hook never breaks the turn.
439
+ _parked = getattr(sink, "marathon_parked", None)
440
+ if _parked is not None:
441
+ try:
442
+ _parked(str(frame.get("reason", "") or ""))
443
+ except Exception:
444
+ pass
445
+ return ""
446
+
447
+ elif ftype == "final":
448
+ # In a MARATHON a `final` is a PER-MILESTONE result, NOT the end
449
+ # of the run -> keep streaming (the goal ends on marathon_complete
450
+ # / marathon_paused, or a user-stop `final` with stopped=true). A
451
+ # non-marathon coding turn's `final` is terminal (unchanged).
452
+ if marathon and not frame.get("stopped"):
453
+ continue
454
+ _text = frame.get("text", "")
455
+ return _tag + _text if _text else ""
456
+ finally:
457
+ # Never leak a pulled-ahead __anext__ past the loop (e.g. an
458
+ # exit while a consent race's carry is still pending).
459
+ if carry_task is not None:
460
+ await _retire(carry_task)
461
+
462
+ return ""
463
+
464
+ _result_delays = (0.5, 2.0, 5.0) # class attr — tests override per instance
465
+
466
+ async def _post_result(self, client, session_id: str, out: dict) -> None:
467
+ # Never raises into the stream loop. W1: bounded transient retries —
468
+ # the kernel dedups by req_id, so a duplicate post is safe, and this
469
+ # cuts outage recovery from the kernel's tool-wait expiry (up to
470
+ # 65min for bash) to seconds. Final fallback unchanged: give up
471
+ # silently, kernel re-dispatches the same req_id.
472
+ for delay in (0.0,) + tuple(self._result_delays):
473
+ if delay:
474
+ await asyncio.sleep(delay)
475
+ try:
476
+ headers = await self._headers()
477
+ r = await client.post(f"/v1/agent/sessions/{session_id}/result",
478
+ json=out, headers=headers)
479
+ if r.status_code < 500:
480
+ return
481
+ except Exception:
482
+ continue
483
+
484
+ async def stop(self) -> None:
485
+ """P5g: server-side stop for Esc/Ctrl-C. Posts a cancel for the
486
+ in-flight turn so the kernel tears down server-side (not just the
487
+ local asyncio task). Fail-soft — the local task.cancel() the dock
488
+ already does is what actually tears the UI down, so a network error
489
+ here must never raise into the key-binding handler."""
490
+ if not self.session_id:
491
+ return
492
+ try:
493
+ import httpx
494
+
495
+ headers = await self._headers()
496
+ async with httpx.AsyncClient(base_url=self.cfg.api_url, timeout=10) as client:
497
+ await client.post(
498
+ f"/v1/agent/sessions/{self.session_id}/cancel", headers=headers,
499
+ )
500
+ except Exception:
501
+ pass