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.
Files changed (105) hide show
  1. {webbee-0.3.19 → webbee-0.3.20}/CHANGELOG.md +17 -0
  2. {webbee-0.3.19 → webbee-0.3.20}/PKG-INFO +1 -1
  3. {webbee-0.3.19 → webbee-0.3.20}/pyproject.toml +1 -1
  4. webbee-0.3.20/src/webbee/__init__.py +1 -0
  5. webbee-0.3.20/src/webbee/active_sessions.py +79 -0
  6. webbee-0.3.20/src/webbee/mode_store.py +59 -0
  7. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/repl.py +88 -19
  8. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/steer.py +20 -2
  9. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/thread.py +12 -2
  10. webbee-0.3.20/tests/conftest.py +18 -0
  11. webbee-0.3.20/tests/test_active_sessions.py +201 -0
  12. webbee-0.3.20/tests/test_mode_store.py +124 -0
  13. {webbee-0.3.19 → webbee-0.3.20}/tests/test_repl.py +295 -1
  14. {webbee-0.3.19 → webbee-0.3.20}/tests/test_steer.py +92 -0
  15. {webbee-0.3.19 → webbee-0.3.20}/tests/test_thread.py +85 -0
  16. webbee-0.3.19/src/webbee/__init__.py +0 -1
  17. {webbee-0.3.19 → webbee-0.3.20}/.github/workflows/publish.yml +0 -0
  18. {webbee-0.3.19 → webbee-0.3.20}/.gitignore +0 -0
  19. {webbee-0.3.19 → webbee-0.3.20}/LICENSE +0 -0
  20. {webbee-0.3.19 → webbee-0.3.20}/README.md +0 -0
  21. {webbee-0.3.19 → webbee-0.3.20}/install.sh +0 -0
  22. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/account.py +0 -0
  23. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/banner_art.py +0 -0
  24. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/boot.py +0 -0
  25. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/checkpoints.py +0 -0
  26. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/cli.py +0 -0
  27. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/clipboard.py +0 -0
  28. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/coding_context.py +0 -0
  29. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/commands.py +0 -0
  30. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/config.py +0 -0
  31. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/consent.py +0 -0
  32. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/details.py +0 -0
  33. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/events.py +0 -0
  34. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/frames.py +0 -0
  35. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/home.py +0 -0
  36. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/http.py +0 -0
  37. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/__init__.py +0 -0
  38. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/chunker.py +0 -0
  39. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/embed.py +0 -0
  40. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/graph.py +0 -0
  41. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/indexer.py +0 -0
  42. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/models.py +0 -0
  43. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/query.py +0 -0
  44. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/service.py +0 -0
  45. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/store.py +0 -0
  46. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/vectors.py +0 -0
  47. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/intel/watch.py +0 -0
  48. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/output_pane.py +0 -0
  49. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/queue_panel.py +0 -0
  50. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/reflow.py +0 -0
  51. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/remote.py +0 -0
  52. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/render.py +0 -0
  53. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/repo.py +0 -0
  54. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/selection.py +0 -0
  55. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/session.py +0 -0
  56. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/sessions.py +0 -0
  57. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/sizing.py +0 -0
  58. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/slots.py +0 -0
  59. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/stream.py +0 -0
  60. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/tabs.py +0 -0
  61. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/todo_panel.py +0 -0
  62. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/tokens.py +0 -0
  63. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/tools.py +0 -0
  64. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/tui.py +0 -0
  65. {webbee-0.3.19 → webbee-0.3.20}/src/webbee/update.py +0 -0
  66. {webbee-0.3.19 → webbee-0.3.20}/tests/__init__.py +0 -0
  67. {webbee-0.3.19 → webbee-0.3.20}/tests/test_account.py +0 -0
  68. {webbee-0.3.19 → webbee-0.3.20}/tests/test_checkpoints.py +0 -0
  69. {webbee-0.3.19 → webbee-0.3.20}/tests/test_cli.py +0 -0
  70. {webbee-0.3.19 → webbee-0.3.20}/tests/test_clipboard.py +0 -0
  71. {webbee-0.3.19 → webbee-0.3.20}/tests/test_commands.py +0 -0
  72. {webbee-0.3.19 → webbee-0.3.20}/tests/test_config.py +0 -0
  73. {webbee-0.3.19 → webbee-0.3.20}/tests/test_cpc_contract_stable.py +0 -0
  74. {webbee-0.3.19 → webbee-0.3.20}/tests/test_details.py +0 -0
  75. {webbee-0.3.19 → webbee-0.3.20}/tests/test_events.py +0 -0
  76. {webbee-0.3.19 → webbee-0.3.20}/tests/test_freeze_fix.py +0 -0
  77. {webbee-0.3.19 → webbee-0.3.20}/tests/test_home.py +0 -0
  78. {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_chunker.py +0 -0
  79. {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_embed.py +0 -0
  80. {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_graph.py +0 -0
  81. {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_indexer.py +0 -0
  82. {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_no_numpy.py +0 -0
  83. {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_query.py +0 -0
  84. {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_service.py +0 -0
  85. {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_store.py +0 -0
  86. {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_vectors.py +0 -0
  87. {webbee-0.3.19 → webbee-0.3.20}/tests/test_intel_watch.py +0 -0
  88. {webbee-0.3.19 → webbee-0.3.20}/tests/test_marathon.py +0 -0
  89. {webbee-0.3.19 → webbee-0.3.20}/tests/test_packaging.py +0 -0
  90. {webbee-0.3.19 → webbee-0.3.20}/tests/test_reflow.py +0 -0
  91. {webbee-0.3.19 → webbee-0.3.20}/tests/test_render.py +0 -0
  92. {webbee-0.3.19 → webbee-0.3.20}/tests/test_repo.py +0 -0
  93. {webbee-0.3.19 → webbee-0.3.20}/tests/test_session.py +0 -0
  94. {webbee-0.3.19 → webbee-0.3.20}/tests/test_sessions.py +0 -0
  95. {webbee-0.3.19 → webbee-0.3.20}/tests/test_sizing.py +0 -0
  96. {webbee-0.3.19 → webbee-0.3.20}/tests/test_slots.py +0 -0
  97. {webbee-0.3.19 → webbee-0.3.20}/tests/test_stream.py +0 -0
  98. {webbee-0.3.19 → webbee-0.3.20}/tests/test_tabs.py +0 -0
  99. {webbee-0.3.19 → webbee-0.3.20}/tests/test_todo_panel.py +0 -0
  100. {webbee-0.3.19 → webbee-0.3.20}/tests/test_tokens.py +0 -0
  101. {webbee-0.3.19 → webbee-0.3.20}/tests/test_tools.py +0 -0
  102. {webbee-0.3.19 → webbee-0.3.20}/tests/test_tui.py +0 -0
  103. {webbee-0.3.19 → webbee-0.3.20}/tests/test_tui_hardening.py +0 -0
  104. {webbee-0.3.19 → webbee-0.3.20}/tests/test_update.py +0 -0
  105. {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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.19
3
+ Version: 0.3.20
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.19"
3
+ version = "0.3.20"
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.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 and replay the durable thread. ONLY
319
- the very first session slot the process ever creates does either; every
320
- later tab (first=False) skips both. Returns `(slot, replayed)` (FIX7e)
321
- -- `replayed` is `boot.replay_thread`'s own display-message count (0 when
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, mode, resources=resources,
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.mode = next_mode(slot.mode)
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.mode = res.new_mode
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.mode = mode
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.mode = "autopilot"
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, client=shared_client))
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 it when the repl actually gave
150
- # us one, so back-compat call sites stay untouched.
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) -> dict:
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
- r = await _request(cfg, client, "GET", f"/v1/agent/sessions/{session_id}/pending-steer", token=token)
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"))