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.
- {webbee-0.3.6 → webbee-0.3.7}/CHANGELOG.md +10 -0
- {webbee-0.3.6 → webbee-0.3.7}/PKG-INFO +1 -1
- {webbee-0.3.6 → webbee-0.3.7}/pyproject.toml +1 -1
- webbee-0.3.7/src/webbee/__init__.py +1 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/render.py +14 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/repl.py +39 -9
- webbee-0.3.7/src/webbee/session.py +512 -0
- webbee-0.3.7/src/webbee/steer.py +106 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/thread.py +20 -1
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_marathon.py +2 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_render.py +18 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_repl.py +114 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_session.py +269 -0
- webbee-0.3.7/tests/test_steer.py +266 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_thread.py +113 -0
- webbee-0.3.6/src/webbee/__init__.py +0 -1
- webbee-0.3.6/src/webbee/session.py +0 -361
- {webbee-0.3.6 → webbee-0.3.7}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/.gitignore +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/LICENSE +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/README.md +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/install.sh +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/account.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/cli.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/commands.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/config.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/details.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/events.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/frames.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/remote.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/repo.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/sessions.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/stream.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/tools.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/tui.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/src/webbee/update.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/__init__.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_account.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_cli.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_clipboard.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_commands.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_config.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_details.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_events.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_query.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_service.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_store.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_packaging.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_repo.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_sessions.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_stream.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_tools.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_tui.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.6 → webbee-0.3.7}/tests/test_update.py +0 -0
- {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
|
|
@@ -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,
|
|
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
|
|
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
|
|
293
|
+
return
|
|
282
294
|
_sink.end_turn(text)
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|