webbee 0.3.6__tar.gz → 0.3.7__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 (81) hide show
  1. {webbee-0.3.6 → webbee-0.3.7}/CHANGELOG.md +10 -0
  2. {webbee-0.3.6 → webbee-0.3.7}/PKG-INFO +1 -1
  3. {webbee-0.3.6 → webbee-0.3.7}/pyproject.toml +1 -1
  4. webbee-0.3.7/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/render.py +14 -0
  6. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/repl.py +39 -9
  7. webbee-0.3.7/src/webbee/session.py +512 -0
  8. webbee-0.3.7/src/webbee/steer.py +106 -0
  9. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/thread.py +20 -1
  10. {webbee-0.3.6 → webbee-0.3.7}/tests/test_marathon.py +2 -0
  11. {webbee-0.3.6 → webbee-0.3.7}/tests/test_render.py +18 -0
  12. {webbee-0.3.6 → webbee-0.3.7}/tests/test_repl.py +114 -0
  13. {webbee-0.3.6 → webbee-0.3.7}/tests/test_session.py +269 -0
  14. webbee-0.3.7/tests/test_steer.py +266 -0
  15. {webbee-0.3.6 → webbee-0.3.7}/tests/test_thread.py +113 -0
  16. webbee-0.3.6/src/webbee/__init__.py +0 -1
  17. webbee-0.3.6/src/webbee/session.py +0 -361
  18. {webbee-0.3.6 → webbee-0.3.7}/.github/workflows/publish.yml +0 -0
  19. {webbee-0.3.6 → webbee-0.3.7}/.gitignore +0 -0
  20. {webbee-0.3.6 → webbee-0.3.7}/LICENSE +0 -0
  21. {webbee-0.3.6 → webbee-0.3.7}/README.md +0 -0
  22. {webbee-0.3.6 → webbee-0.3.7}/install.sh +0 -0
  23. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/account.py +0 -0
  24. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/banner_art.py +0 -0
  25. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/checkpoints.py +0 -0
  26. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/cli.py +0 -0
  27. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/clipboard.py +0 -0
  28. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/commands.py +0 -0
  29. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/config.py +0 -0
  30. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/details.py +0 -0
  31. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/events.py +0 -0
  32. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/frames.py +0 -0
  33. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/__init__.py +0 -0
  34. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/chunker.py +0 -0
  35. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/embed.py +0 -0
  36. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/graph.py +0 -0
  37. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/indexer.py +0 -0
  38. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/models.py +0 -0
  39. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/query.py +0 -0
  40. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/service.py +0 -0
  41. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/store.py +0 -0
  42. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/vectors.py +0 -0
  43. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/watch.py +0 -0
  44. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/output_pane.py +0 -0
  45. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/remote.py +0 -0
  46. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/repo.py +0 -0
  47. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/sessions.py +0 -0
  48. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/stream.py +0 -0
  49. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/tools.py +0 -0
  50. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/tui.py +0 -0
  51. {webbee-0.3.6 → webbee-0.3.7}/src/webbee/update.py +0 -0
  52. {webbee-0.3.6 → webbee-0.3.7}/tests/__init__.py +0 -0
  53. {webbee-0.3.6 → webbee-0.3.7}/tests/test_account.py +0 -0
  54. {webbee-0.3.6 → webbee-0.3.7}/tests/test_checkpoints.py +0 -0
  55. {webbee-0.3.6 → webbee-0.3.7}/tests/test_cli.py +0 -0
  56. {webbee-0.3.6 → webbee-0.3.7}/tests/test_clipboard.py +0 -0
  57. {webbee-0.3.6 → webbee-0.3.7}/tests/test_commands.py +0 -0
  58. {webbee-0.3.6 → webbee-0.3.7}/tests/test_config.py +0 -0
  59. {webbee-0.3.6 → webbee-0.3.7}/tests/test_cpc_contract_stable.py +0 -0
  60. {webbee-0.3.6 → webbee-0.3.7}/tests/test_details.py +0 -0
  61. {webbee-0.3.6 → webbee-0.3.7}/tests/test_events.py +0 -0
  62. {webbee-0.3.6 → webbee-0.3.7}/tests/test_freeze_fix.py +0 -0
  63. {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_chunker.py +0 -0
  64. {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_embed.py +0 -0
  65. {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_graph.py +0 -0
  66. {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_indexer.py +0 -0
  67. {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_no_numpy.py +0 -0
  68. {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_query.py +0 -0
  69. {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_service.py +0 -0
  70. {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_store.py +0 -0
  71. {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_vectors.py +0 -0
  72. {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_watch.py +0 -0
  73. {webbee-0.3.6 → webbee-0.3.7}/tests/test_packaging.py +0 -0
  74. {webbee-0.3.6 → webbee-0.3.7}/tests/test_repo.py +0 -0
  75. {webbee-0.3.6 → webbee-0.3.7}/tests/test_sessions.py +0 -0
  76. {webbee-0.3.6 → webbee-0.3.7}/tests/test_stream.py +0 -0
  77. {webbee-0.3.6 → webbee-0.3.7}/tests/test_tools.py +0 -0
  78. {webbee-0.3.6 → webbee-0.3.7}/tests/test_tui.py +0 -0
  79. {webbee-0.3.6 → webbee-0.3.7}/tests/test_tui_hardening.py +0 -0
  80. {webbee-0.3.6 → webbee-0.3.7}/tests/test_update.py +0 -0
  81. {webbee-0.3.6 → webbee-0.3.7}/tests/test_version.py +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.7
4
+
5
+ - Terminal liveness: a consent answered from Telegram/panel no longer
6
+ freezes the terminal — the `approve? y/n` prompt dismisses itself
7
+ ("answered from another surface") and the turn keeps rendering live.
8
+ - Idle pickup: an open, idle terminal now picks up instructions sent from
9
+ Telegram/panel within seconds and runs them as normal turns, tagged with
10
+ their origin. If no terminal is open, the instruction waits (up to an
11
+ hour) and Telegram says so instead of failing.
12
+
3
13
  ## 0.3.6
4
14
 
5
15
  - Boot replay shows only the conversation: flattened tool traffic
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.6
3
+ Version: 0.3.7
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.6"
3
+ version = "0.3.7"
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.7"
@@ -233,6 +233,20 @@ class RichSink:
233
233
  self.console.print(_pad(Text(f"{who}{tag}: {_clean(text)}", style=_BEE)))
234
234
  self._nudge()
235
235
 
236
+ def consent_dismissed(self, note: str) -> None:
237
+ """Liveness A: the pending consent was answered on ANOTHER surface
238
+ (e.g. relayed from Telegram) — the kernel park is over. Retire the
239
+ pinned prompt so the dock leaves `approve? y/n`: cancel the armed
240
+ Future (consent_pending() flips False, so the toolbar repaints out of
241
+ the consent state on the next _nudge) and print ONE note-style line
242
+ so the scrollback shows why the y/n prompt vanished."""
243
+ if self._consent is not None and not self._consent.done():
244
+ self._consent.cancel()
245
+ self._consent = None
246
+ self._consent_summary = ""
247
+ self.console.print(_pad(Text(_clean(note), style=_BEE)))
248
+ self._nudge()
249
+
236
250
  def clear(self) -> None:
237
251
  """/clear: wipe the pane/screen + reset the session counters."""
238
252
  self.console.clear()
@@ -134,6 +134,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
134
134
  intel = None # assigned by _boot -- IntelService, or None (off/base-install/boot failure)
135
135
  shadow = None # assigned by _boot -- ShadowGit, or None (git unavailable / boot failure)
136
136
  watcher_task = None # assigned by _boot -- background watchfiles task, cancelled on exit
137
+ steer_task = None # assigned by _boot -- idle-steer poller (webbee.steer), cancelled on exit
137
138
 
138
139
  def _cycle() -> None:
139
140
  state["mode"] = next_mode(state["mode"])
@@ -269,21 +270,42 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
269
270
 
270
271
  # A task for the agent.
271
272
  _sink.user_echo(line)
273
+ await _run_turn(line)
274
+ return "continue"
275
+
276
+ async def _run_turn(line: str, surface: str = "") -> None:
277
+ """ONE agent turn -- the SAME path for a typed line and an idle-steer
278
+ pickup (liveness v2 §B), which threads the queued item's origin
279
+ `surface` into the turn so the kernel stamps provenance start-path.
280
+ Only the echo differs at the call sites: user_echo for a typed line,
281
+ foreign_turn for a remote one."""
272
282
  _sink.begin_turn()
283
+ kw = {"surface": surface} if surface else {}
273
284
  try:
274
- text = await agent.run(line, _sink, marathon=not once, goal=(line if not once else ""))
285
+ text = await agent.run(line, _sink, marathon=not once,
286
+ goal=(line if not once else ""), **kw)
275
287
  except (KeyboardInterrupt, asyncio.CancelledError):
276
288
  _sink.abort()
277
289
  _sink.note("Interrupted.")
278
- return "continue"
290
+ return
279
291
  except Exception as e: # network/auth/etc — never crash the REPL
280
292
  _sink.note(f"Error: {type(e).__name__}: {e}")
281
- return "continue"
293
+ return
282
294
  _sink.end_turn(text)
283
- return "continue"
295
+
296
+ async def _steer_submit(text: str, surface: str) -> None:
297
+ """webbee.steer hands a drained remote instruction here: render it as
298
+ the remote user's own line, then run it as a normal turn."""
299
+ _sink.foreign_turn(surface, "user", text)
300
+ await _run_turn(text, surface=surface)
301
+
302
+ def _cancel_background() -> None:
303
+ for _t in (watcher_task, steer_task):
304
+ if _t is not None:
305
+ _t.cancel()
284
306
 
285
307
  async def _boot(s) -> None:
286
- nonlocal _sink, agent, intel, watcher_task, shadow
308
+ nonlocal _sink, agent, intel, watcher_task, shadow, steer_task
287
309
  _sink = s
288
310
  # Cache git branch OFF the event loop (subprocess.run blocks it). Only
289
311
  # /status reads it; recomputing it per input line froze the dock.
@@ -333,6 +355,16 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
333
355
  except Exception:
334
356
  shadow = None
335
357
  agent = agent_factory(cfg, token_provider, workspace, state["mode"])
358
+ # Liveness v2 §B: idle-steer pickup. All poll/drain logic lives in
359
+ # webbee.steer -- this is wiring only: the sink's live turn state
360
+ # gates polling, the agent's session id (once a turn has run) is the
361
+ # gateway truth, and _steer_submit is the normal turn path.
362
+ from webbee import steer as _steer
363
+ steer_task = asyncio.ensure_future(_steer.poll_idle_steer(
364
+ cfg, token_provider, workspace=workspace, marathon=not once,
365
+ is_busy=lambda: bool(getattr(_sink, "is_busy", None) and _sink.is_busy()),
366
+ live_session_id=lambda: getattr(agent, "session_id", ""),
367
+ submit=_steer_submit))
336
368
 
337
369
  if use_dock:
338
370
  ok = False
@@ -369,8 +401,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
369
401
  stop_turn=lambda: agent.stop(),
370
402
  )
371
403
  finally:
372
- if watcher_task is not None:
373
- watcher_task.cancel()
404
+ _cancel_background()
374
405
  except Exception:
375
406
  ok = False
376
407
  finally:
@@ -404,5 +435,4 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
404
435
  if await _handle(line) == "exit":
405
436
  return
406
437
  finally:
407
- if watcher_task is not None:
408
- watcher_task.cancel()
438
+ _cancel_background()
@@ -0,0 +1,512 @@
1
+ import asyncio
2
+ import os
3
+ import subprocess
4
+ from dataclasses import dataclass
5
+
6
+ from webbee.frames import (
7
+ _FOREIGN_ACTIONABLE_TYPES,
8
+ _MARATHON_FACT_TYPES,
9
+ _first_time,
10
+ _origin_tag,
11
+ _progress_text,
12
+ handle_action_frame,
13
+ handle_step_finished,
14
+ handle_step_started,
15
+ marathon_note,
16
+ render_foreign_frame,
17
+ )
18
+
19
+
20
+ def _is_foreign_frame(frame: dict, own_task_id: str) -> bool:
21
+ """C7 filter predicate (extracted verbatim from run()'s inline check): a
22
+ frame stamped with a DIFFERENT task_id on the shared persistent stream,
23
+ when it is origin-stamped or actionable, belongs to another turn and is
24
+ DISPLAY-ONLY for this client. task_id absent (legacy kernels) -> own."""
25
+ ftid = frame.get("task_id", "")
26
+ return bool(ftid and own_task_id and ftid != own_task_id and (
27
+ frame.get("origin") or frame.get("type") in _FOREIGN_ACTIONABLE_TYPES))
28
+
29
+
30
+ async def _retire(task) -> None:
31
+ """Cancel + drain a racing task so it never outlives the frame loop
32
+ (liveness A hygiene). Absorbs the loser's outcome — CancelledError, a
33
+ late result, StopAsyncIteration, a transport error — retirement must
34
+ never introduce a new failure path. An externally-delivered cancellation
35
+ of the CALLER (task finished before our cancel landed) still propagates."""
36
+ task.cancel()
37
+ try:
38
+ await task
39
+ except asyncio.CancelledError:
40
+ if not task.cancelled():
41
+ raise # ours was the outer cancel — propagate
42
+ except Exception:
43
+ pass
44
+
45
+
46
+ def _dismiss_consent(sink) -> None:
47
+ """Tell the UI the pending consent prompt was answered on ANOTHER surface
48
+ (liveness A). Guarded like the other optional sink hooks — a minimal sink
49
+ without `consent_dismissed` (or a UI bug) must never break the frame loop."""
50
+ fn = getattr(sink, "consent_dismissed", None)
51
+ if fn is None:
52
+ return
53
+ try:
54
+ fn("↩ answered from another surface")
55
+ except Exception:
56
+ pass
57
+
58
+
59
+ @dataclass
60
+ class _ConsentRace:
61
+ """Outcome of racing the local consent prompt against the stream
62
+ (liveness A). Exactly one shape per race:
63
+ * out — the prompt resolved locally (today's path): POST it; a
64
+ pulled-ahead __anext__ may ride along in carry_task.
65
+ * carry_frame — the stream won: the park ended elsewhere; the prompt was
66
+ cancelled + dismissed, nothing is posted, and the pulled
67
+ frame is handed back to the loop for normal handling.
68
+ * stream_ended — StopAsyncIteration mid-consent: exit as stream end.
69
+ """
70
+ out: dict | None = None
71
+ carry_task: asyncio.Task | None = None
72
+ carry_frame: dict | None = None
73
+ stream_ended: bool = False
74
+
75
+
76
+ def handle_tool_request(frame: dict, executor) -> dict:
77
+ """Run a (kernel-pre-approved) local tool. The kernel gates write/bash
78
+ consent via confirm_request BEFORE dispatching, so a tool_request here is
79
+ always cleared to run. NEVER raises: a tool that errored (or a bug in the
80
+ executor) must STILL return a result so the caller posts it back and the
81
+ kernel's dispatch unblocks — an unposted result hangs the turn and freezes
82
+ the whole dock."""
83
+ rid = frame.get("req_id")
84
+ try:
85
+ result = executor.run(frame.get("tool", ""), frame.get("args", {}))
86
+ except Exception as e:
87
+ result = {"ok": False, "content": f"local tool crashed: {type(e).__name__}: {e}"}
88
+ return {"req_id": rid, "result": result}
89
+
90
+
91
+ async def handle_confirm_request(frame: dict, mode: str, ask_consent) -> dict:
92
+ """ICNLI consent path. The client does NOT interpret consent words — it
93
+ relays the user's RAW reply; the kernel brain interprets intent
94
+ (safe-by-default). autopilot/plan are explicit and never prompt.
95
+ `ask_consent(app_id, tool, args)` is an async coroutine returning the raw
96
+ reply (resolved through the pinned dock, or a sync fallback reader)."""
97
+ req_id = frame["req_id"]
98
+ if mode == "autopilot":
99
+ return {"req_id": req_id, "result": {"approved": True}}
100
+ if mode == "plan":
101
+ return {"req_id": req_id, "result": {"approved": False, "reason": "plan_mode"}}
102
+ try:
103
+ raw = await ask_consent(frame.get("app_id", ""), frame.get("tool", ""), frame.get("args", {}))
104
+ except Exception:
105
+ # Consent UI failed -> decline (safe-by-default). NEVER leave the kernel
106
+ # hanging on an unanswered confirm — an unposted result froze the dock.
107
+ return {"req_id": req_id, "result": {"approved": False, "reason": "consent_error"}}
108
+ return {"req_id": req_id, "result": {"consent_reply": raw}}
109
+
110
+
111
+ def build_coding_context(workspace_root: str, intel=None) -> dict:
112
+ """Snapshot handed to the cloud brain: cwd (realpath), `git status -sb`
113
+ (empty for non-git/any error), a bounded newline-joined file tree, and —
114
+ when a ready intel service is injected — the precomputed repo_profile.
115
+ The profile is READ from the already-built index (cheap); indexing never
116
+ happens inline here."""
117
+ cwd = os.path.realpath(workspace_root)
118
+ try:
119
+ proc = subprocess.run(
120
+ ["git", "status", "-sb"], cwd=cwd,
121
+ capture_output=True, text=True, timeout=10,
122
+ )
123
+ git = proc.stdout if proc.returncode == 0 else ""
124
+ except (OSError, subprocess.SubprocessError):
125
+ git = ""
126
+ paths = []
127
+ for dirpath, dirnames, filenames in os.walk(cwd):
128
+ dirnames[:] = [d for d in dirnames if d != ".git" and not d.startswith(".")]
129
+ for fn in filenames:
130
+ paths.append(os.path.relpath(os.path.join(dirpath, fn), cwd))
131
+ if len(paths) >= 200:
132
+ break
133
+ if len(paths) >= 200:
134
+ break
135
+ from webbee.repo import compute_repo_key, find_repo_root
136
+ root = find_repo_root(cwd)
137
+ d = {"cwd": cwd, "git": git, "tree": "\n".join(paths),
138
+ "repo_key": compute_repo_key(root), "repo_root": root}
139
+ if intel is not None and getattr(intel, "ready", False):
140
+ try:
141
+ d["repo_profile"] = intel.repo_profile()
142
+ except Exception:
143
+ pass
144
+ return d
145
+
146
+
147
+ def detect_verify_cmd(repo_root: str) -> str:
148
+ """Best-effort project test command, CLIENT-detected from the repo layout.
149
+
150
+ SECURITY-load-bearing: in a marathon the kernel runs ONLY this command as
151
+ proof-of-done — the cloud brain can never author a shell command. So this
152
+ is deliberately small + honest: a fixed command per recognised ecosystem,
153
+ NO guessing beyond these. An empty string means "no known runner" → the
154
+ kernel falls back to an LLM-judged done-check. Checked in a stable order."""
155
+ import json as _json
156
+
157
+ root = repo_root or "."
158
+
159
+ def _has(name: str) -> bool:
160
+ return os.path.isfile(os.path.join(root, name))
161
+
162
+ if _has("pyproject.toml") or _has("setup.cfg") or _has("tox.ini"):
163
+ return "pytest -q"
164
+ if _has("package.json"):
165
+ try:
166
+ with open(os.path.join(root, "package.json"), encoding="utf-8") as f:
167
+ pkg = _json.load(f)
168
+ scripts = pkg.get("scripts") if isinstance(pkg, dict) else None
169
+ if isinstance(scripts, dict) and scripts.get("test"):
170
+ return "npm test"
171
+ except (OSError, ValueError):
172
+ pass
173
+ if _has("Cargo.toml"):
174
+ return "cargo test"
175
+ if _has("go.mod"):
176
+ return "go test ./..."
177
+ if _has("Makefile"):
178
+ try:
179
+ import re as _re
180
+ with open(os.path.join(root, "Makefile"), encoding="utf-8") as f:
181
+ if _re.search(r"(?m)^test:", f.read()):
182
+ return "make test"
183
+ except OSError:
184
+ pass
185
+ return ""
186
+
187
+
188
+ def _summary(result: dict) -> str:
189
+ """One-line summary of a tool result for the UI."""
190
+ content = str(result.get("content", ""))
191
+ first = content.strip().splitlines()[0] if content.strip() else ""
192
+ return first[:120]
193
+
194
+
195
+ class AgentSession:
196
+ """Client-side driver for one coding turn against the Imperal cloud.
197
+ The brain runs server-side; this is the hands — it streams kernel-
198
+ pre-approved tool_request frames over SSE, runs each tool locally, relays
199
+ confirm_request replies RAW for the brain to interpret, drives the sink
200
+ for live UI, and posts results back until a final frame arrives.
201
+
202
+ P1: one POST per turn (server reloads the shared webbee-terminal thread,
203
+ so context carries across turns). Persistent signal-based sessions are P3."""
204
+
205
+ def __init__(self, cfg, token_provider, workspace_root: str, mode: str = "default", intel=None,
206
+ shadow=None) -> None:
207
+ self.cfg = cfg
208
+ self.token_provider = token_provider
209
+ self.workspace_root = workspace_root
210
+ self.mode = mode
211
+ self.session_id: str = ""
212
+ self.steps: list = []
213
+ self._task_id: str = ""
214
+ self._intel = intel # IntelService, or None (base install / boot failure)
215
+ self._shadow = shadow # ShadowGit, or None (git unavailable / boot failure)
216
+
217
+ async def _headers(self) -> dict:
218
+ token = await self.token_provider()
219
+ return {"Authorization": f"Bearer {token}"}
220
+
221
+ async def run(self, task: str, sink, *, marathon: bool = False, goal: str = "",
222
+ surface: str = "") -> str:
223
+ import httpx
224
+
225
+ from webbee.tools import LocalToolExecutor
226
+ from imperal_mcp.client import ImperalClient
227
+
228
+ # Offload to a worker thread — build_coding_context runs sync
229
+ # subprocess.run(git status, timeout=10) + os.walk; inline on the dock's
230
+ # asyncio loop it froze the whole UI at every turn start.
231
+ coding_context = await asyncio.to_thread(build_coding_context, self.workspace_root, self._intel)
232
+ if marathon:
233
+ # verify_cmd is CLIENT-detected here and carried in coding_context —
234
+ # the trusted proof-of-done the kernel runs (never brain-authored).
235
+ root = coding_context.get("repo_root") or coding_context.get("cwd") or self.workspace_root
236
+ verify_cmd = await asyncio.to_thread(detect_verify_cmd, root)
237
+ coding_context = {**coding_context, "verify_cmd": verify_cmd}
238
+ imperal_id = await ImperalClient(self.cfg, self.token_provider).whoami()
239
+ executor = LocalToolExecutor(self.workspace_root, indexer=self._intel,
240
+ shadow=self._shadow)
241
+
242
+ body = {"user_id": imperal_id, "task": task, "coding_context": coding_context}
243
+ if surface:
244
+ # Liveness v2 §B: an idle-steer pickup carries the queued item's
245
+ # origin surface so the kernel adopts it start-path (provenance +
246
+ # [surface] tags). Additive-only -- a typed turn's body is
247
+ # byte-identical to before.
248
+ body["surface"] = surface
249
+ if marathon:
250
+ body["marathon"] = True
251
+ body["goal"] = goal
252
+
253
+ headers = await self._headers()
254
+ async with httpx.AsyncClient(base_url=self.cfg.api_url, timeout=60) as client:
255
+ resp = await client.post(
256
+ "/v1/agent/sessions",
257
+ json=body,
258
+ headers=headers,
259
+ )
260
+ resp.raise_for_status()
261
+ _sess = resp.json()
262
+ session_id = _sess["session_id"]
263
+ start_id = _sess.get("last_id", "0-0")
264
+ self.session_id = session_id
265
+ self._task_id = _sess.get("task_id", "")
266
+ self.steps = []
267
+
268
+ seen: dict = {} # req_id -> already-posted result (at-least-once dedup)
269
+ # Slice-5 T9: id-sets shared across BOTH vocabularies for EXT tools
270
+ # (the kernel reuses the SAME tc["id"] as step_id there), so a step
271
+ # announced by one vocabulary is never re-announced by its
272
+ # dual-emitted twin. step_labels carries a v2 step_started's label
273
+ # forward to its later step_finished (which carries no app_id/tool
274
+ # of its own — facts-only). local_ids tracks LOCAL-tool v2 step_ids,
275
+ # which use a disjoint id space from the tool_request round trip
276
+ # (see webbee.frames' module docstring) and are a pure no-op.
277
+ started: set = set()
278
+ finished: set = set()
279
+ step_labels: dict = {}
280
+ local_ids: set = set()
281
+ from webbee.stream import stream_frames
282
+ stream = stream_frames(client, session_id, self._headers, start_id=start_id)
283
+ # Liveness A: explicit __anext__ pulls (not `async for`) so a
284
+ # pending local consent can RACE the stream. Between iterations at
285
+ # most ONE of carry_task/carry_frame is set — a consent race hands
286
+ # ownership of its pulled-ahead pull back to this loop: a pending
287
+ # task when consent won, an already-pulled frame when the stream
288
+ # won. Everything else is byte-identical to the old async-for.
289
+ carry_task = None # a still-pending __anext__ task (consent won)
290
+ carry_frame = None # an already-pulled frame (the stream won)
291
+ try:
292
+ while True:
293
+ if carry_frame is not None:
294
+ frame, carry_frame = carry_frame, None
295
+ else:
296
+ if carry_task is not None:
297
+ pull, carry_task = carry_task, None
298
+ else:
299
+ pull = asyncio.ensure_future(stream.__anext__())
300
+ try:
301
+ frame = await pull
302
+ except StopAsyncIteration:
303
+ break
304
+ ftype = frame.get("type")
305
+
306
+ # A frame from a DIFFERENT turn on the shared persistent stream
307
+ # (task_id absent on legacy kernels -> treated as own). C7 safety:
308
+ # foreign actionable frames are NEVER executed/consented and NEVER
309
+ # end this turn -- but instead of vanishing they (and any origin-
310
+ # stamped cross-surface display frame, e.g. a Telegram-steered
311
+ # turn's progress) now render ONE tagged, display-only line.
312
+ if _is_foreign_frame(frame, self._task_id):
313
+ render_foreign_frame(frame, sink)
314
+ continue
315
+
316
+ # Live steer topology: a Telegram/panel-steered turn keeps THIS
317
+ # client's task_id (the terminal stays the sole executor) with
318
+ # `origin` stamped -- tag the text renders below; everything
319
+ # else (execution, dedup, consent, accounting) is unchanged.
320
+ _tag = _origin_tag(frame)
321
+
322
+ if ftype == "tool_request":
323
+ rid = frame.get("req_id")
324
+ sid = str(rid or "")
325
+ if rid in seen:
326
+ out = seen[rid]
327
+ else:
328
+ # UI rendering is guarded so it can never block the
329
+ # result POST below (an unposted result hangs the kernel
330
+ # dispatch and freezes the dock).
331
+ if _first_time(sid, started):
332
+ try:
333
+ sink.tool_start(_tag + frame.get("tool", ""), frame.get("args", {}))
334
+ except Exception:
335
+ pass
336
+ out = await asyncio.to_thread(handle_tool_request, frame, executor)
337
+ res = out["result"]
338
+ if _first_time(sid, finished):
339
+ try:
340
+ sink.tool_result(_tag + frame.get("tool", ""), bool(res.get("ok")), _summary(res))
341
+ self.steps.append({"step_id": sid,
342
+ "label": frame.get("tool", ""),
343
+ "ok": bool(res.get("ok"))})
344
+ except Exception:
345
+ pass
346
+ seen[rid] = out
347
+ await self._post_result(client, session_id, out)
348
+
349
+ elif ftype == "confirm_request":
350
+ rid = frame.get("req_id")
351
+ if rid in seen:
352
+ await self._post_result(client, session_id, seen[rid])
353
+ else:
354
+ if self.mode == "plan":
355
+ sink.plan_blocked(frame.get("tool", ""))
356
+ # Liveness A: the local prompt races the stream so a
357
+ # consent answered from ANOTHER surface (Telegram
358
+ # relay) unfreezes this terminal instead of leaving
359
+ # the dock stuck on `approve? y/n`.
360
+ race = await self._race_consent(frame, sink, stream)
361
+ carry_task, carry_frame = race.carry_task, race.carry_frame
362
+ if race.stream_ended:
363
+ break
364
+ if race.out is not None:
365
+ seen[rid] = race.out
366
+ await self._post_result(client, session_id, race.out)
367
+
368
+ elif ftype == "panel_release_required":
369
+ sink.panel_release(frame.get("panel_url", ""), frame.get("summary", ""))
370
+
371
+ elif ftype == "action": # R2 — ext-tool call (server-side) surfaced in the feed
372
+ handle_action_frame(frame, sink, started, finished, self.steps)
373
+
374
+ elif ftype == "step_started": # v2 (Slice-5 T8 dual-emit)
375
+ handle_step_started(frame, sink, started, step_labels, local_ids)
376
+
377
+ elif ftype == "step_finished": # v2 (Slice-5 T8 dual-emit)
378
+ handle_step_finished(frame, sink, finished, step_labels, self.steps, local_ids)
379
+
380
+ elif ftype == "thinking": # system-driven reasoning -> the 💭 block
381
+ _text = _progress_text(frame)
382
+ (getattr(sink, "thinking", None) or sink.progress)(_tag + _text if _text else "")
383
+
384
+ elif ftype == "progress": # P2 — dual-reads llm_text (v2) / text (legacy)
385
+ _text = _progress_text(frame)
386
+ sink.progress(_tag + _text if _text else "")
387
+
388
+ elif ftype == "usage": # P2 — cumulative tokens + credits (Slice C; raw $ stays server-side)
389
+ sink.usage(
390
+ int(frame.get("tokens", 0) or 0),
391
+ int(frame.get("credits", 0) or 0),
392
+ )
393
+
394
+ elif ftype == "marathon_complete": # U4 — the whole GOAL is done: terminal
395
+ return frame.get("text", "")
396
+
397
+ elif ftype in _MARATHON_FACT_TYPES: # U4 — marathon plan/milestone/pause
398
+ # Facts-only; render ONE human-readable line. Guarded: a
399
+ # minimal sink (no `note`) simply drops the fact rather than
400
+ # crashing the turn (the stream reader already tolerates
401
+ # unknown frame types by ignoring them).
402
+ _note = getattr(sink, "note", None)
403
+ if _note is not None:
404
+ _note(marathon_note(frame))
405
+ if ftype == "marathon_paused":
406
+ # Parked (out-of-credits / consent / runaway) -> end the
407
+ # turn so the dock leaves "working"; the note shows why, and
408
+ # the run resumes on the user's next reply.
409
+ return ""
410
+
411
+ elif ftype == "final":
412
+ # In a MARATHON a `final` is a PER-MILESTONE result, NOT the end
413
+ # of the run -> keep streaming (the goal ends on marathon_complete
414
+ # / marathon_paused, or a user-stop `final` with stopped=true). A
415
+ # non-marathon coding turn's `final` is terminal (unchanged).
416
+ if marathon and not frame.get("stopped"):
417
+ continue
418
+ _text = frame.get("text", "")
419
+ return _tag + _text if _text else ""
420
+ finally:
421
+ # Never leak a pulled-ahead __anext__ past the loop (e.g. an
422
+ # exit while a consent race's carry is still pending).
423
+ if carry_task is not None:
424
+ await _retire(carry_task)
425
+
426
+ return ""
427
+
428
+ async def _race_consent(self, frame: dict, sink, stream) -> _ConsentRace:
429
+ """LIVE BUG fix (Valentin, 2026-07-15): a consent answered from
430
+ Telegram resolves the kernel park, but the frame loop used to block
431
+ INLINE in handle_confirm_request — the dock froze on `approve? y/n`
432
+ and the rest of the turn never rendered until a local keypress. The
433
+ local prompt now RACES the stream:
434
+ * the prompt resolves first -> POST it (today's path, unchanged);
435
+ the pulled-ahead frame rides back to the loop in carry_task.
436
+ * a re-published confirm_request with the SAME req_id (kernel
437
+ I-MARATHON-USER-WAKE presence re-publish) -> NOT an answer; the
438
+ prompt stays up and both racers keep going.
439
+ * any other OWN-turn frame -> the park ended elsewhere (answered
440
+ from another surface / superseded): cancel the prompt, dismiss the
441
+ dock, POST NOTHING (the kernel accepts only the FIRST result per
442
+ issued req_id), hand the frame back for normal handling.
443
+ * foreign frames (C7) stay display-only and never end the park.
444
+ Consent is never auto-approved here — autopilot/plan resolve instantly
445
+ inside handle_confirm_request exactly as before."""
446
+ rid = frame.get("req_id")
447
+ consent_task = asyncio.ensure_future(
448
+ handle_confirm_request(frame, self.mode, sink.ask_consent))
449
+ pull = None
450
+ try:
451
+ while True:
452
+ if pull is None:
453
+ pull = asyncio.ensure_future(stream.__anext__())
454
+ await asyncio.wait({consent_task, pull},
455
+ return_when=asyncio.FIRST_COMPLETED)
456
+ if consent_task.done():
457
+ # The local answer wins — even when both racers finished in
458
+ # the same cycle the pulled frame is NOT lost: the loop owns
459
+ # carry_task and processes it next.
460
+ return _ConsentRace(out=consent_task.result(), carry_task=pull)
461
+ nxt_pull, pull = pull, None
462
+ try:
463
+ nxt = nxt_pull.result()
464
+ except StopAsyncIteration:
465
+ # Stream ended mid-consent: retire the prompt safely and
466
+ # exit exactly as today's stream end (no result posted).
467
+ await _retire(consent_task)
468
+ return _ConsentRace(stream_ended=True)
469
+ if _is_foreign_frame(nxt, self._task_id):
470
+ render_foreign_frame(nxt, sink)
471
+ continue
472
+ if nxt.get("type") == "confirm_request" and nxt.get("req_id") == rid:
473
+ continue
474
+ await _retire(consent_task)
475
+ _dismiss_consent(sink)
476
+ return _ConsentRace(carry_frame=nxt)
477
+ except BaseException:
478
+ # Hygiene on any unexpected exit (StreamAuthError from the pull,
479
+ # outer cancellation, a consent-task crash): no racer may leak.
480
+ for t in (consent_task, pull):
481
+ if t is not None and not t.done():
482
+ await _retire(t)
483
+ raise
484
+
485
+ async def _post_result(self, client, session_id: str, out: dict) -> None:
486
+ # Best-effort: a result POST must NEVER raise into the stream loop — that
487
+ # would abort the turn and leave the kernel's dispatch hanging (frozen
488
+ # dock). On a transient failure the kernel's tool-wait timeout recovers.
489
+ try:
490
+ headers = await self._headers()
491
+ await client.post(f"/v1/agent/sessions/{session_id}/result", json=out, headers=headers)
492
+ except Exception:
493
+ pass
494
+
495
+ async def stop(self) -> None:
496
+ """P5g: server-side stop for Esc/Ctrl-C. Posts a cancel for the
497
+ in-flight turn so the kernel tears down server-side (not just the
498
+ local asyncio task). Fail-soft — the local task.cancel() the dock
499
+ already does is what actually tears the UI down, so a network error
500
+ here must never raise into the key-binding handler."""
501
+ if not self.session_id:
502
+ return
503
+ try:
504
+ import httpx
505
+
506
+ headers = await self._headers()
507
+ async with httpx.AsyncClient(base_url=self.cfg.api_url, timeout=10) as client:
508
+ await client.post(
509
+ f"/v1/agent/sessions/{self.session_id}/cancel", headers=headers,
510
+ )
511
+ except Exception:
512
+ pass