webbee 0.3.19__tar.gz → 0.3.20__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.19 → webbee-0.3.20}/CHANGELOG.md +17 -0
- {webbee-0.3.19 → webbee-0.3.20}/PKG-INFO +1 -1
- {webbee-0.3.19 → webbee-0.3.20}/pyproject.toml +1 -1
- webbee-0.3.20/src/webbee/__init__.py +1 -0
- webbee-0.3.20/src/webbee/active_sessions.py +79 -0
- webbee-0.3.20/src/webbee/mode_store.py +59 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/repl.py +88 -19
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/steer.py +20 -2
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/thread.py +12 -2
- webbee-0.3.20/tests/conftest.py +18 -0
- webbee-0.3.20/tests/test_active_sessions.py +201 -0
- webbee-0.3.20/tests/test_mode_store.py +124 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_repl.py +295 -1
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_steer.py +92 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_thread.py +85 -0
- webbee-0.3.19/src/webbee/__init__.py +0 -1
- {webbee-0.3.19 → webbee-0.3.20}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/.gitignore +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/LICENSE +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/README.md +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/install.sh +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/account.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/boot.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/cli.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/commands.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/config.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/consent.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/details.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/events.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/frames.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/home.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/http.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/queue_panel.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/reflow.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/remote.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/render.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/repo.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/selection.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/session.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/sessions.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/sizing.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/slots.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/stream.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/tabs.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/todo_panel.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/tokens.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/tools.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/tui.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/src/webbee/update.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/__init__.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_account.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_cli.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_clipboard.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_commands.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_config.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_details.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_events.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_home.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_query.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_service.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_store.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_marathon.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_packaging.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_reflow.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_render.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_repo.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_session.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_sessions.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_sizing.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_slots.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_stream.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_tabs.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_todo_panel.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_tokens.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_tools.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_tui.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_update.py +0 -0
- {webbee-0.3.19 → webbee-0.3.20}/tests/test_version.py +0 -0
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.20
|
|
4
|
+
|
|
5
|
+
Webbee remembers your mode per project (autopilot always asks again),
|
|
6
|
+
reports it so the panel shows the truth, and tells you at start when a
|
|
7
|
+
session is already running or waiting for an approval.
|
|
8
|
+
|
|
9
|
+
- Each repo remembers its own coding mode across restarts — open Webbee
|
|
10
|
+
again in the same project and it picks up right where you left it.
|
|
11
|
+
Autopilot is the one exception: it always asks again, every time, at this
|
|
12
|
+
terminal — never silently resumed from a previous run.
|
|
13
|
+
- Your terminal now reports its real coding mode back, so the panel and
|
|
14
|
+
Telegram stop guessing and show the truth.
|
|
15
|
+
- On start, if a session is already running in this repo, Webbee tells you
|
|
16
|
+
it reattached — and if it's waiting on an approval, that the prompt will
|
|
17
|
+
re-show (or that you can approve it from the panel). If a DIFFERENT repo
|
|
18
|
+
has a session parked on an approval, you get a one-line heads-up too.
|
|
19
|
+
|
|
3
20
|
## 0.3.19
|
|
4
21
|
|
|
5
22
|
The browser wave: Webbee Code becomes a browser inside your terminal.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.20"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Boot reattach notice (T6.3, coding-remote flow perfection): a best-effort
|
|
2
|
+
client for the gateway's own-user session-discovery endpoint, plus the pure
|
|
3
|
+
decision logic that turns its listing into 0-2 boot-time notes.
|
|
4
|
+
|
|
5
|
+
`fetch_active_sessions` is the house pattern used across this package
|
|
6
|
+
(sessions.py/thread.py): (cfg, token_provider), lazy httpx (or the repl's
|
|
7
|
+
shared keep-alive client), Bearer auth, and it never raises -- a listing
|
|
8
|
+
someone forgot to close, or a gateway that hasn't shipped this route yet,
|
|
9
|
+
must never become a boot-time crash or delay. `boot_reattach_notice` is pure
|
|
10
|
+
(no I/O) so a test drives it directly with fake session dicts."""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def _get(cfg, client, path: str, token: str):
|
|
15
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
16
|
+
if client is not None:
|
|
17
|
+
r = await client.get(path, headers=headers)
|
|
18
|
+
r.raise_for_status()
|
|
19
|
+
return r
|
|
20
|
+
import httpx
|
|
21
|
+
async with httpx.AsyncClient(base_url=cfg.api_url, timeout=10) as c:
|
|
22
|
+
r = await c.get(path, headers=headers)
|
|
23
|
+
r.raise_for_status()
|
|
24
|
+
return r
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def fetch_active_sessions(cfg, token_provider, *, client=None) -> list[dict]:
|
|
28
|
+
"""This user's own Running coding/marathon sessions across every
|
|
29
|
+
terminal/surface (gateway T2: `GET /v1/agent/sessions/active`, user JWT).
|
|
30
|
+
Best-effort: ANY failure (network, auth, an older gateway without the
|
|
31
|
+
route yet) returns [] rather than raising. `client=` reuses the repl's
|
|
32
|
+
shared keep-alive AsyncClient; None falls back to a fresh per-call one
|
|
33
|
+
(same convention as sessions.py/thread.py)."""
|
|
34
|
+
try:
|
|
35
|
+
token = await token_provider()
|
|
36
|
+
r = await _get(cfg, client, "/v1/agent/sessions/active", token)
|
|
37
|
+
return (r.json() or {}).get("sessions", [])
|
|
38
|
+
except Exception:
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def boot_reattach_notice(sessions: list[dict], repo_key: str) -> list[str]:
|
|
43
|
+
"""Decide what, if anything, to tell the user about their OTHER running
|
|
44
|
+
sessions once THIS terminal's own boot replay has finished. `sessions`
|
|
45
|
+
is the gateway's own-user listing above; `repo_key` is this terminal's
|
|
46
|
+
own repo identity (webbee.repo.compute_repo_key) -- the gateway keys
|
|
47
|
+
every session id `{kind}-{imperal_id}-r{repo_key}` (steer.derive_session_id),
|
|
48
|
+
so a session belongs to THIS repo iff its id ends with `-r{repo_key}`.
|
|
49
|
+
|
|
50
|
+
Returns 0-2 lines, rendered verbatim by the caller (one sink.note per
|
|
51
|
+
line):
|
|
52
|
+
* a session already Running in THIS repo -- a reattach note, plus (when
|
|
53
|
+
it is parked on an approval) one more line pointing at the panel;
|
|
54
|
+
* ANY session in another repo waiting on an approval -- ONE dim
|
|
55
|
+
pointer, never repeated per session and never naming the repo/session
|
|
56
|
+
id (no internals beyond what the panel already shows).
|
|
57
|
+
|
|
58
|
+
Pure (no I/O, no imports) -- a test drives it directly with fake dicts."""
|
|
59
|
+
suffix = f"-r{repo_key}"
|
|
60
|
+
own = None
|
|
61
|
+
other_parked = False
|
|
62
|
+
for s in sessions:
|
|
63
|
+
sid = str(s.get("session_id") or "")
|
|
64
|
+
if sid.endswith(suffix):
|
|
65
|
+
if own is None:
|
|
66
|
+
own = s
|
|
67
|
+
elif s.get("pending_consent"):
|
|
68
|
+
other_parked = True
|
|
69
|
+
|
|
70
|
+
lines = []
|
|
71
|
+
if own is not None:
|
|
72
|
+
lines.append("reattached to your running session — it keeps its history")
|
|
73
|
+
if own.get("pending_consent"):
|
|
74
|
+
lines.append("it is waiting for an approval — the prompt will re-show; "
|
|
75
|
+
"you can also approve from the panel")
|
|
76
|
+
if other_parked:
|
|
77
|
+
lines.append("you have a parked session waiting for approval in another repo "
|
|
78
|
+
"— open webbee there or approve from the panel")
|
|
79
|
+
return lines
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Mode persistence per-repo (T6.1, coding-remote flow perfection): remembers
|
|
2
|
+
the coding mode across process restarts by writing a tiny marker file under
|
|
3
|
+
``~/.cache/webbee/mode-{repo_key}`` -- the SAME repo identity (webbee.repo.
|
|
4
|
+
compute_repo_key) intel/checkpoints already key their own per-repo caches by.
|
|
5
|
+
Plain text, ONE mode string per file: no JSON, no schema to version.
|
|
6
|
+
|
|
7
|
+
Fail-soft in BOTH directions, by design:
|
|
8
|
+
* `load_mode` -- a missing file, an unreadable dir, or corrupt/garbage
|
|
9
|
+
content all degrade to None. The caller's own process-baseline mode is
|
|
10
|
+
always the fallback (repl.run_repl's `mode` argument), so a bad cache is
|
|
11
|
+
exactly as safe as no cache at all.
|
|
12
|
+
* `save_mode` -- a write failure (read-only home, disk full, no
|
|
13
|
+
permission) is silently dropped: losing the memory is a far smaller
|
|
14
|
+
problem than crashing the terminal over a nice-to-have.
|
|
15
|
+
|
|
16
|
+
SECURITY POSTURE (Valentin-chosen, matches the terminal-local autopilot
|
|
17
|
+
confirm ladder in repl._confirm_autopilot): autopilot is NEVER remembered.
|
|
18
|
+
`save_mode` downgrades an autopilot write to 'default' before it ever
|
|
19
|
+
touches disk -- autopilot auto-approves every tool call, so upgrading to it
|
|
20
|
+
must be re-confirmed EXPLICITLY every process, never silently resumed from a
|
|
21
|
+
stale file the next time this repo is opened."""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
|
|
26
|
+
from webbee.repo import compute_repo_key, find_repo_root
|
|
27
|
+
|
|
28
|
+
_CACHE_DIR = os.path.expanduser("~/.cache/webbee") # test seam: monkeypatch this name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _path_for(workspace: str) -> str:
|
|
32
|
+
repo_key = compute_repo_key(find_repo_root(workspace))
|
|
33
|
+
return os.path.join(_CACHE_DIR, f"mode-{repo_key}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_mode(workspace: str) -> "str | None":
|
|
37
|
+
"""The remembered mode for `workspace`'s repo, or None on no file / ANY
|
|
38
|
+
error (corrupt content, permission denied, missing dir, git failure
|
|
39
|
+
inside compute_repo_key) -- never raises."""
|
|
40
|
+
try:
|
|
41
|
+
with open(_path_for(workspace), "r", encoding="utf-8") as f:
|
|
42
|
+
mode = f.read().strip()
|
|
43
|
+
return mode or None
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_mode(workspace: str, mode: str) -> None:
|
|
49
|
+
"""Remember `mode` for `workspace`'s repo -- except autopilot, which is
|
|
50
|
+
downgraded to 'default' before it's written (see module docstring).
|
|
51
|
+
Never raises: a write failure just means the next boot won't remember,
|
|
52
|
+
no worse than before this feature existed."""
|
|
53
|
+
try:
|
|
54
|
+
os.makedirs(_CACHE_DIR, exist_ok=True)
|
|
55
|
+
stored = mode if mode != "autopilot" else "default"
|
|
56
|
+
with open(_path_for(workspace), "w", encoding="utf-8") as f:
|
|
57
|
+
f.write(stored)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
@@ -180,6 +180,24 @@ def _say(slot: SessionSlot, msg: str) -> None:
|
|
|
180
180
|
notify()
|
|
181
181
|
|
|
182
182
|
|
|
183
|
+
def set_slot_mode(slot: SessionSlot, mode: str) -> None:
|
|
184
|
+
"""The ONE place a slot's mode is ever assigned (T6.1, mode persistence
|
|
185
|
+
per-repo) -- mutates `slot.mode` + the live `agent.mode` together
|
|
186
|
+
(exactly the pair every mutation site duplicated before this helper),
|
|
187
|
+
then remembers the choice for THIS repo via mode_store.save_mode so the
|
|
188
|
+
next process boot in it resumes here (autopilot itself is downgraded to
|
|
189
|
+
'default' inside save_mode -- never persisted, see its own docstring).
|
|
190
|
+
Replaces the three inline mutation sites: Shift-Tab `_cycle`, the /mode
|
|
191
|
+
command action, and the remote `_on_mode` flip (including
|
|
192
|
+
`_confirm_autopilot`'s approval branch). Module-level so tests drive it
|
|
193
|
+
directly, same testing philosophy as `_slot_ctx`/`_live_session_id`."""
|
|
194
|
+
slot.mode = mode
|
|
195
|
+
if slot.agent is not None:
|
|
196
|
+
slot.agent.mode = mode
|
|
197
|
+
from webbee.mode_store import save_mode
|
|
198
|
+
save_mode(slot.workspace, mode)
|
|
199
|
+
|
|
200
|
+
|
|
183
201
|
def _live_session_id(slots: SlotManager) -> str:
|
|
184
202
|
"""The idle-steer poller's live-session seam (map §1): the ACTIVE slot's
|
|
185
203
|
agent session id, or "" with no crash when the active slot has no agent
|
|
@@ -307,6 +325,30 @@ def _resolve_agent_factory(agent_factory, bundle: dict):
|
|
|
307
325
|
return lambda c, tp, ws, m: AgentSession(c, tp, ws, m, intel=bundle["intel"], shadow=bundle["shadow"])
|
|
308
326
|
|
|
309
327
|
|
|
328
|
+
async def _note_reattach(cfg, token_provider, workspace, sink) -> None:
|
|
329
|
+
"""Boot reattach notice (T6.3, coding-remote flow perfection): best-
|
|
330
|
+
effort, entirely swallowed on any failure -- same division of labor as
|
|
331
|
+
`boot.replay_thread` right above its call site: a session listing is a
|
|
332
|
+
nice-to-have, never a boot blocker (a gateway that hasn't shipped the
|
|
333
|
+
route yet, a network blip, or a bad token all just mean silence).
|
|
334
|
+
Computes THIS repo's key off-loop (a git subprocess), fetches the user's
|
|
335
|
+
own active-session listing, and renders whatever
|
|
336
|
+
`active_sessions.boot_reattach_notice` decides -- 0-2 plain sink.note
|
|
337
|
+
lines, never raw session ids or other internals."""
|
|
338
|
+
try:
|
|
339
|
+
from webbee.active_sessions import boot_reattach_notice, fetch_active_sessions
|
|
340
|
+
from webbee.repo import compute_repo_key, find_repo_root
|
|
341
|
+
|
|
342
|
+
def _repo_key() -> str: # git subprocess -- keep off the event loop
|
|
343
|
+
return compute_repo_key(find_repo_root(workspace))
|
|
344
|
+
repo_key = await asyncio.to_thread(_repo_key)
|
|
345
|
+
sessions = await fetch_active_sessions(cfg, token_provider)
|
|
346
|
+
for line in boot_reattach_notice(sessions, repo_key):
|
|
347
|
+
sink.note(line)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
|
|
310
352
|
async def _finish_slot(cfg, token_provider, workspace, mode, *, resources: WorkspaceResources,
|
|
311
353
|
agent_factory, intel_factory, shadow_factory, pane, sink, first: bool,
|
|
312
354
|
account) -> "tuple[SessionSlot, int]":
|
|
@@ -315,10 +357,11 @@ async def _finish_slot(cfg, token_provider, workspace, mode, *, resources: Works
|
|
|
315
357
|
pane (or None, fallback has no dock) + sink: resolve/boot the per-
|
|
316
358
|
WORKSPACE resources bundle, build the agent, wire the sink's local queue
|
|
317
359
|
to THIS slot's own deque, and -- gated by `first` (map §6 replay
|
|
318
|
-
landmine) -- show the welcome banner
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
360
|
+
landmine) -- show the welcome banner, replay the durable thread, and
|
|
361
|
+
(T6.3) note any OTHER running/parked session. ONLY the very first
|
|
362
|
+
session slot the process ever creates does any of that; every later tab
|
|
363
|
+
(first=False) skips all three. Returns `(slot, replayed)` (FIX7e) --
|
|
364
|
+
`replayed` is `boot.replay_thread`'s own display-message count (0 when
|
|
322
365
|
`first=False`, or the replay itself skipped/failed/found nothing)."""
|
|
323
366
|
bundle = await _resources_bundle(cfg, workspace, resources, intel_factory, shadow_factory)
|
|
324
367
|
factory = _resolve_agent_factory(agent_factory, bundle)
|
|
@@ -342,6 +385,9 @@ async def _finish_slot(cfg, token_provider, workspace, mode, *, resources: Works
|
|
|
342
385
|
# show the wrong (or duplicate) history, so only the first session
|
|
343
386
|
# slot the process ever creates gets it.
|
|
344
387
|
replayed = await boot.replay_thread(cfg, token_provider, sink)
|
|
388
|
+
# T6.3: after replay, tell the user about any session already
|
|
389
|
+
# running elsewhere -- same "first slot only" guard as replay above.
|
|
390
|
+
await _note_reattach(cfg, token_provider, workspace, sink)
|
|
345
391
|
return slot, replayed
|
|
346
392
|
|
|
347
393
|
|
|
@@ -356,15 +402,24 @@ async def _make_session_slot(cfg, token_provider, workspace, mode, *, resources:
|
|
|
356
402
|
caller — `/new`, `_home_input`, every direct test) unless
|
|
357
403
|
`_with_replayed=True` (FIX7e — the dock boot's OWN first-slot call,
|
|
358
404
|
the only caller that needs `boot.replay_thread`'s count to decide
|
|
359
|
-
`slots.active_idx`), which returns `(slot, replayed)` instead.
|
|
405
|
+
`slots.active_idx`), which returns `(slot, replayed)` instead.
|
|
406
|
+
|
|
407
|
+
T6.1 (mode persistence per-repo): `mode` is only the PROCESS baseline —
|
|
408
|
+
a repo this terminal remembered a mode for (mode_store.save_mode, via
|
|
409
|
+
`set_slot_mode`) overrides it here, off-loop (a git subprocess sits
|
|
410
|
+
behind it). Never autopilot: `save_mode` itself downgrades that to
|
|
411
|
+
'default' before it's ever written, so a loaded mode is always safe to
|
|
412
|
+
resume silently."""
|
|
360
413
|
from webbee import tui
|
|
414
|
+
from webbee.mode_store import load_mode
|
|
361
415
|
from webbee.render import RichSink
|
|
362
416
|
from webbee.sizing import get_size
|
|
363
417
|
|
|
418
|
+
effective_mode = await asyncio.to_thread(load_mode, workspace) or mode
|
|
364
419
|
width, _height = get_size(None) # pre-app: same fallback tui.run_session's own sizing uses
|
|
365
420
|
pane = tui.OutputPane(width=width)
|
|
366
421
|
sink = RichSink(console=pane.console, on_output=pane.notify)
|
|
367
|
-
slot, replayed = await _finish_slot(cfg, token_provider, workspace,
|
|
422
|
+
slot, replayed = await _finish_slot(cfg, token_provider, workspace, effective_mode, resources=resources,
|
|
368
423
|
agent_factory=agent_factory, intel_factory=intel_factory,
|
|
369
424
|
shadow_factory=shadow_factory, pane=pane, sink=sink,
|
|
370
425
|
first=first, account=account)
|
|
@@ -501,6 +556,15 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
501
556
|
slots = SlotManager()
|
|
502
557
|
resources = WorkspaceResources() # per-workspace boot cache (map §6)
|
|
503
558
|
steer_task = None # assigned by _spawn_steer -- idle-steer poller (webbee.steer), cancelled on exit
|
|
559
|
+
# T6.2 (applied-mode report): the ONE session slot the steer poller is
|
|
560
|
+
# actually bound to -- set right before `_spawn_steer()` runs, on EITHER
|
|
561
|
+
# path (dock's first session slot / the fallback loop's only slot).
|
|
562
|
+
# `_spawn_steer`'s `mode_getter` reads `.mode` off THIS slot, never
|
|
563
|
+
# `slots.active()` -- the poller's `sid` (derived, or the FIRST turn's
|
|
564
|
+
# live_session_id) is bound to this same slot's workspace, so its
|
|
565
|
+
# applied-mode report must follow that slot even if a later tab becomes
|
|
566
|
+
# the one on screen.
|
|
567
|
+
first_session_slot: SessionSlot | None = None
|
|
504
568
|
# Task 5 (map contract item 5): the dock fills this at construction time
|
|
505
569
|
# with `switch`/`close` -- the SAME `_switch_to`/`_close_flow` a click or
|
|
506
570
|
# a key goes through (history swap, close note) -- so /tab, /new and
|
|
@@ -531,9 +595,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
531
595
|
|
|
532
596
|
def _cycle() -> None:
|
|
533
597
|
slot = slots.active()
|
|
534
|
-
slot
|
|
535
|
-
if slot.agent is not None:
|
|
536
|
-
slot.agent.mode = slot.mode
|
|
598
|
+
set_slot_mode(slot, next_mode(slot.mode))
|
|
537
599
|
|
|
538
600
|
async def _handle(line: str, slot: SessionSlot) -> str:
|
|
539
601
|
"""Process one input line AGAINST an EXPLICIT slot (FIX1, W4a final
|
|
@@ -734,9 +796,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
734
796
|
if res.action == "mode" and res.new_mode:
|
|
735
797
|
# Home never reaches here -- "mode" is in _HOME_GATED_ACTIONS,
|
|
736
798
|
# so Home's own slot.mode is never mutated by a remote /mode.
|
|
737
|
-
slot
|
|
738
|
-
if slot.agent is not None:
|
|
739
|
-
slot.agent.mode = res.new_mode
|
|
799
|
+
set_slot_mode(slot, res.new_mode)
|
|
740
800
|
if res.message:
|
|
741
801
|
_say(slot, res.message)
|
|
742
802
|
return "continue"
|
|
@@ -857,9 +917,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
857
917
|
if mode not in _MODES or mode == slot.mode:
|
|
858
918
|
return
|
|
859
919
|
if mode != "autopilot":
|
|
860
|
-
slot
|
|
861
|
-
if slot.agent is not None:
|
|
862
|
-
slot.agent.mode = mode
|
|
920
|
+
set_slot_mode(slot, mode)
|
|
863
921
|
slot.sink.note(f"mode → {mode} [{surface}]")
|
|
864
922
|
return
|
|
865
923
|
asyncio.ensure_future(_confirm_autopilot(surface))
|
|
@@ -883,9 +941,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
883
941
|
ok = await ask(f"{surface} asks to switch to autopilot "
|
|
884
942
|
f"(auto-approve everything) — allow? [y/n]")
|
|
885
943
|
if ok:
|
|
886
|
-
slot
|
|
887
|
-
if slot.agent is not None:
|
|
888
|
-
slot.agent.mode = "autopilot"
|
|
944
|
+
set_slot_mode(slot, "autopilot")
|
|
889
945
|
slot.sink.note(f"mode → autopilot [{surface}] — approved at this terminal")
|
|
890
946
|
else:
|
|
891
947
|
slot.sink.note(f"autopilot request from {surface} declined — mode stays {slot.mode}")
|
|
@@ -897,6 +953,16 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
897
953
|
# PUBLIC `resources.bundles()`, never `_by_root` poked directly).
|
|
898
954
|
_cancel_all_background(steer_task, slots, resources)
|
|
899
955
|
|
|
956
|
+
def _mode_getter() -> str:
|
|
957
|
+
"""T6.2 (applied-mode report): the mode of the slot the poller is
|
|
958
|
+
ACTUALLY bound to -- `first_session_slot`, set right before
|
|
959
|
+
`_spawn_steer()` runs below, never `slots.active()` (a different tab
|
|
960
|
+
than the one the poller's `sid` resolves to would report the wrong
|
|
961
|
+
mode). "" before that slot exists (can't happen once the poller is
|
|
962
|
+
actually spawned, but keeps this a total function for tests calling
|
|
963
|
+
it standalone)."""
|
|
964
|
+
return first_session_slot.mode if first_session_slot is not None else ""
|
|
965
|
+
|
|
900
966
|
def _spawn_steer() -> None:
|
|
901
967
|
nonlocal steer_task
|
|
902
968
|
from webbee import steer as _steer
|
|
@@ -914,7 +980,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
914
980
|
cfg, token_provider, workspace=workspace, marathon=not once,
|
|
915
981
|
is_busy=_poller_busy,
|
|
916
982
|
live_session_id=lambda: _live_session_id(slots),
|
|
917
|
-
submit=_steer_submit, on_mode=_on_mode,
|
|
983
|
+
submit=_steer_submit, on_mode=_on_mode, mode_getter=_mode_getter,
|
|
984
|
+
client=shared_client))
|
|
918
985
|
|
|
919
986
|
if use_dock:
|
|
920
987
|
ok = False
|
|
@@ -942,6 +1009,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
942
1009
|
intel_factory=intel_factory, shadow_factory=shadow_factory,
|
|
943
1010
|
first=True, account=account, _with_replayed=True)
|
|
944
1011
|
slots.add(session_slot)
|
|
1012
|
+
first_session_slot = session_slot # T6.2: what the poller's mode_getter reads
|
|
945
1013
|
# FIX7e (W4a final review — land-on-Home): the session tab
|
|
946
1014
|
# is only the more useful FIRST screen when the boot replay
|
|
947
1015
|
# actually showed something -- a fresh/empty thread lands on
|
|
@@ -1025,6 +1093,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1025
1093
|
shadow_factory=shadow_factory, pane=None, sink=sink,
|
|
1026
1094
|
first=True, account=account)
|
|
1027
1095
|
slots.add(fallback_slot)
|
|
1096
|
+
first_session_slot = fallback_slot # T6.2: what the poller's mode_getter reads
|
|
1028
1097
|
_spawn_steer()
|
|
1029
1098
|
try:
|
|
1030
1099
|
while True:
|
|
@@ -81,6 +81,7 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
|
|
|
81
81
|
submit, marathon: bool = True,
|
|
82
82
|
live_session_id=lambda: "",
|
|
83
83
|
on_mode=None,
|
|
84
|
+
mode_getter=None,
|
|
84
85
|
interval: float = _POLL_INTERVAL,
|
|
85
86
|
client=None,
|
|
86
87
|
idle_after_s: float = 300.0,
|
|
@@ -115,6 +116,19 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
|
|
|
115
116
|
terminal stops opening a fresh TCP+TLS handshake
|
|
116
117
|
every tick, forever. None (tests / no repl-owned
|
|
117
118
|
client) keeps today's per-call client.
|
|
119
|
+
* mode_getter() -- sync, optional (T6.2, applied-mode report);
|
|
120
|
+
returns the CURRENT mode of the session actually
|
|
121
|
+
being polled (the repl passes a getter reading
|
|
122
|
+
THAT slot -- never blindly `slots.active()`,
|
|
123
|
+
which can be a different tab than the one `sid`
|
|
124
|
+
above resolves to). Read fresh every tick and
|
|
125
|
+
handed to fetch_pending_steer as `mode=`, so the
|
|
126
|
+
gateway's applied-mode record follows a local
|
|
127
|
+
mode change (Shift-Tab, /mode, a remote flip)
|
|
128
|
+
within ONE poll tick -- ~`interval` seconds later
|
|
129
|
+
at the fast cadence, no extra poke required.
|
|
130
|
+
None/"" omits the query param entirely (old
|
|
131
|
+
wiring, or no session polled yet).
|
|
118
132
|
|
|
119
133
|
Adaptive cadence (Task 12): after `idle_after_s` (default 5 minutes)
|
|
120
134
|
without activity, the tick relaxes from `interval` (4s) to
|
|
@@ -146,9 +160,13 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
|
|
|
146
160
|
cfg, token_provider, workspace, marathon=marathon)
|
|
147
161
|
sid = derived
|
|
148
162
|
# Old-style test doubles for fetch_pending_steer don't accept
|
|
149
|
-
# a client kwarg -- only pass
|
|
150
|
-
# us one, so back-compat call sites stay
|
|
163
|
+
# a client/mode kwarg -- only pass either when the repl
|
|
164
|
+
# actually gave us one, so back-compat call sites stay
|
|
165
|
+
# untouched.
|
|
151
166
|
fetch_kw = {"client": client} if client is not None else {}
|
|
167
|
+
mode = mode_getter() if mode_getter is not None else ""
|
|
168
|
+
if mode:
|
|
169
|
+
fetch_kw["mode"] = mode
|
|
152
170
|
payload = await fetch_pending_steer(cfg, token_provider, sid, **fetch_kw) or {}
|
|
153
171
|
if payload.get("items"):
|
|
154
172
|
last_active = now() # a successful drain = activity
|
|
@@ -65,7 +65,8 @@ async def fetch_recent_thread(cfg, token_provider, session_id: str, *, client=No
|
|
|
65
65
|
return (r.json() or {}).get("messages", [])
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
async def fetch_pending_steer(cfg, token_provider, session_id: str, *, client=None
|
|
68
|
+
async def fetch_pending_steer(cfg, token_provider, session_id: str, *, client=None,
|
|
69
|
+
mode: str = "") -> dict:
|
|
69
70
|
"""Drain this user's pending-steer state (idle-steer pickup, liveness v2
|
|
70
71
|
§B + full-queue-layer mode adoption) -- the /thread endpoint's sibling,
|
|
71
72
|
same auth. Returns the gateway payload verbatim:
|
|
@@ -77,11 +78,20 @@ async def fetch_pending_steer(cfg, token_provider, session_id: str, *, client=No
|
|
|
77
78
|
* "requested_mode" -- one-shot remote mode request {mode, surface} or
|
|
78
79
|
null (GETDEL on the gateway -- delivered exactly
|
|
79
80
|
once; older gateways omit the key entirely).
|
|
81
|
+
`mode=` (T6.2, applied-mode report): the polled session's CURRENT
|
|
82
|
+
coding mode, appended as `?mode={mode}` when non-empty -- the gateway
|
|
83
|
+
stores it as `applied_mode` so the panel/TG can show the terminal's REAL
|
|
84
|
+
mode instead of guessing. Omitted entirely when "" (default), so an
|
|
85
|
+
older gateway that doesn't expect the query param sees the exact same
|
|
86
|
+
request as before this feature.
|
|
80
87
|
Non-swallowing like fetch_recent_thread above: the poller (webbee.steer)
|
|
81
88
|
wraps each tick in its own try/except. `client=` reuses the repl's shared
|
|
82
89
|
keep-alive client; None keeps the per-call client."""
|
|
83
90
|
token = await token_provider()
|
|
84
|
-
|
|
91
|
+
path = f"/v1/agent/sessions/{session_id}/pending-steer"
|
|
92
|
+
if mode:
|
|
93
|
+
path += f"?mode={mode}"
|
|
94
|
+
r = await _request(cfg, client, "GET", path, token=token)
|
|
85
95
|
return r.json() or {}
|
|
86
96
|
|
|
87
97
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Shared test isolation, autouse across the whole suite.
|
|
2
|
+
|
|
3
|
+
Mode persistence (T6.1) writes a tiny per-repo marker to
|
|
4
|
+
`~/.cache/webbee/mode-{repo_key}` on every mode change -- and the fallback
|
|
5
|
+
REPL loop most tests drive runs with `cwd` inside THIS repo checkout, so an
|
|
6
|
+
un-isolated run would repeatedly overwrite the developer's REAL cache entry
|
|
7
|
+
for this very repo (and could clobber a real webbee session's remembered
|
|
8
|
+
mode with a `pytest` run). Redirecting `webbee.mode_store._CACHE_DIR` to a
|
|
9
|
+
per-test tmp dir keeps every test hermetic, same spirit as this file's own
|
|
10
|
+
`_NoopIntel`/`shadow_factory=lambda cfg, ws: None` test doubles that already
|
|
11
|
+
keep intel/shadow off the developer's real ~/.cache/webbee/intel."""
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture(autouse=True)
|
|
16
|
+
def _isolate_mode_cache(tmp_path, monkeypatch):
|
|
17
|
+
import webbee.mode_store as mode_store
|
|
18
|
+
monkeypatch.setattr(mode_store, "_CACHE_DIR", str(tmp_path / "webbee-mode-cache"))
|