webbee 0.3.20__tar.gz → 0.3.22__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 (107) hide show
  1. {webbee-0.3.20 → webbee-0.3.22}/CHANGELOG.md +23 -0
  2. {webbee-0.3.20 → webbee-0.3.22}/PKG-INFO +1 -1
  3. {webbee-0.3.20 → webbee-0.3.22}/pyproject.toml +1 -1
  4. webbee-0.3.22/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/repl.py +216 -172
  6. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/session.py +12 -1
  7. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/slots.py +5 -0
  8. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/steer.py +31 -4
  9. webbee-0.3.22/src/webbee/worktrees.py +73 -0
  10. {webbee-0.3.20 → webbee-0.3.22}/tests/test_repl.py +354 -70
  11. {webbee-0.3.20 → webbee-0.3.22}/tests/test_session.py +35 -2
  12. {webbee-0.3.20 → webbee-0.3.22}/tests/test_steer.py +88 -0
  13. webbee-0.3.22/tests/test_worktrees.py +119 -0
  14. webbee-0.3.20/src/webbee/__init__.py +0 -1
  15. {webbee-0.3.20 → webbee-0.3.22}/.github/workflows/publish.yml +0 -0
  16. {webbee-0.3.20 → webbee-0.3.22}/.gitignore +0 -0
  17. {webbee-0.3.20 → webbee-0.3.22}/LICENSE +0 -0
  18. {webbee-0.3.20 → webbee-0.3.22}/README.md +0 -0
  19. {webbee-0.3.20 → webbee-0.3.22}/install.sh +0 -0
  20. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/account.py +0 -0
  21. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/active_sessions.py +0 -0
  22. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/banner_art.py +0 -0
  23. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/boot.py +0 -0
  24. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/checkpoints.py +0 -0
  25. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/cli.py +0 -0
  26. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/clipboard.py +0 -0
  27. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/coding_context.py +0 -0
  28. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/commands.py +0 -0
  29. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/config.py +0 -0
  30. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/consent.py +0 -0
  31. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/details.py +0 -0
  32. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/events.py +0 -0
  33. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/frames.py +0 -0
  34. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/home.py +0 -0
  35. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/http.py +0 -0
  36. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/__init__.py +0 -0
  37. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/chunker.py +0 -0
  38. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/embed.py +0 -0
  39. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/graph.py +0 -0
  40. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/indexer.py +0 -0
  41. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/models.py +0 -0
  42. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/query.py +0 -0
  43. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/service.py +0 -0
  44. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/store.py +0 -0
  45. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/vectors.py +0 -0
  46. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/watch.py +0 -0
  47. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/mode_store.py +0 -0
  48. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/output_pane.py +0 -0
  49. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/queue_panel.py +0 -0
  50. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/reflow.py +0 -0
  51. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/remote.py +0 -0
  52. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/render.py +0 -0
  53. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/repo.py +0 -0
  54. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/selection.py +0 -0
  55. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/sessions.py +0 -0
  56. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/sizing.py +0 -0
  57. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/stream.py +0 -0
  58. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/tabs.py +0 -0
  59. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/thread.py +0 -0
  60. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/todo_panel.py +0 -0
  61. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/tokens.py +0 -0
  62. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/tools.py +0 -0
  63. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/tui.py +0 -0
  64. {webbee-0.3.20 → webbee-0.3.22}/src/webbee/update.py +0 -0
  65. {webbee-0.3.20 → webbee-0.3.22}/tests/__init__.py +0 -0
  66. {webbee-0.3.20 → webbee-0.3.22}/tests/conftest.py +0 -0
  67. {webbee-0.3.20 → webbee-0.3.22}/tests/test_account.py +0 -0
  68. {webbee-0.3.20 → webbee-0.3.22}/tests/test_active_sessions.py +0 -0
  69. {webbee-0.3.20 → webbee-0.3.22}/tests/test_checkpoints.py +0 -0
  70. {webbee-0.3.20 → webbee-0.3.22}/tests/test_cli.py +0 -0
  71. {webbee-0.3.20 → webbee-0.3.22}/tests/test_clipboard.py +0 -0
  72. {webbee-0.3.20 → webbee-0.3.22}/tests/test_commands.py +0 -0
  73. {webbee-0.3.20 → webbee-0.3.22}/tests/test_config.py +0 -0
  74. {webbee-0.3.20 → webbee-0.3.22}/tests/test_cpc_contract_stable.py +0 -0
  75. {webbee-0.3.20 → webbee-0.3.22}/tests/test_details.py +0 -0
  76. {webbee-0.3.20 → webbee-0.3.22}/tests/test_events.py +0 -0
  77. {webbee-0.3.20 → webbee-0.3.22}/tests/test_freeze_fix.py +0 -0
  78. {webbee-0.3.20 → webbee-0.3.22}/tests/test_home.py +0 -0
  79. {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_chunker.py +0 -0
  80. {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_embed.py +0 -0
  81. {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_graph.py +0 -0
  82. {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_indexer.py +0 -0
  83. {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_no_numpy.py +0 -0
  84. {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_query.py +0 -0
  85. {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_service.py +0 -0
  86. {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_store.py +0 -0
  87. {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_vectors.py +0 -0
  88. {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_watch.py +0 -0
  89. {webbee-0.3.20 → webbee-0.3.22}/tests/test_marathon.py +0 -0
  90. {webbee-0.3.20 → webbee-0.3.22}/tests/test_mode_store.py +0 -0
  91. {webbee-0.3.20 → webbee-0.3.22}/tests/test_packaging.py +0 -0
  92. {webbee-0.3.20 → webbee-0.3.22}/tests/test_reflow.py +0 -0
  93. {webbee-0.3.20 → webbee-0.3.22}/tests/test_render.py +0 -0
  94. {webbee-0.3.20 → webbee-0.3.22}/tests/test_repo.py +0 -0
  95. {webbee-0.3.20 → webbee-0.3.22}/tests/test_sessions.py +0 -0
  96. {webbee-0.3.20 → webbee-0.3.22}/tests/test_sizing.py +0 -0
  97. {webbee-0.3.20 → webbee-0.3.22}/tests/test_slots.py +0 -0
  98. {webbee-0.3.20 → webbee-0.3.22}/tests/test_stream.py +0 -0
  99. {webbee-0.3.20 → webbee-0.3.22}/tests/test_tabs.py +0 -0
  100. {webbee-0.3.20 → webbee-0.3.22}/tests/test_thread.py +0 -0
  101. {webbee-0.3.20 → webbee-0.3.22}/tests/test_todo_panel.py +0 -0
  102. {webbee-0.3.20 → webbee-0.3.22}/tests/test_tokens.py +0 -0
  103. {webbee-0.3.20 → webbee-0.3.22}/tests/test_tools.py +0 -0
  104. {webbee-0.3.20 → webbee-0.3.22}/tests/test_tui.py +0 -0
  105. {webbee-0.3.20 → webbee-0.3.22}/tests/test_tui_hardening.py +0 -0
  106. {webbee-0.3.20 → webbee-0.3.22}/tests/test_update.py +0 -0
  107. {webbee-0.3.20 → webbee-0.3.22}/tests/test_version.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.22
4
+
5
+ Open the same project in two tabs and they truly run in parallel — each tab
6
+ gets its own isolated working copy automatically; remote messages land in the
7
+ right tab.
8
+
9
+ - Two tabs on the same repo no longer share one working copy: the second tab
10
+ gets its own isolated checkout automatically, so edits from one tab never
11
+ collide with the other. A note in that tab tells you it happened (or, on
12
+ the rare case it couldn't, that the checkout is shared so you know to be
13
+ careful).
14
+ - Telegram/panel messages and remote mode switches now reach the RIGHT tab —
15
+ every open tab is independently steerable, not just whichever one a single
16
+ shared listener happened to be watching.
17
+ - `/tabs` now shows each tab's short id for reference.
18
+
19
+ ## 0.3.21
20
+
21
+ - A mode change sent from the panel or Telegram now reliably reaches your
22
+ coding session — whichever tab is on screen. Previously the request could
23
+ land on the Home tab and vanish silently; the terminal kept its old mode
24
+ while the panel claimed the switch happened.
25
+
3
26
  ## 0.3.20
4
27
 
5
28
  Webbee remembers your mode per project (autopilot always asks again),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.20
3
+ Version: 0.3.22
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.20"
3
+ version = "0.3.22"
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.22"
@@ -105,9 +105,10 @@ async def _inject_into_slot(cfg, token_provider, slot: SessionSlot, text: str,
105
105
 
106
106
 
107
107
  def _gate_busy(sink, turn_ref: dict) -> bool:
108
- """Pure predicate behind `_poller_busy` (module-level so tests drive it
109
- directly, unlike the run_repl closure) -- LOCKOUT-PROOF like
110
- tui._busy_live: busy counts only while the turn TASK recorded in
108
+ """Pure predicate behind every per-slot idle-steer poller's `is_busy`
109
+ seam (`_spawn_slot_poller`, module-level so tests drive it directly,
110
+ unlike the run_repl closure) -- LOCKOUT-PROOF like tui._busy_live: busy
111
+ counts only while the turn TASK recorded in
111
112
  `turn_ref` is genuinely alive. A BaseException-class escape (or a raise
112
113
  inside end_turn) that leaves the sink's _busy flag stuck must no longer
113
114
  starve the idle-steer poller. `turn_ref` is populated ONLY on the dock
@@ -190,7 +191,7 @@ def set_slot_mode(slot: SessionSlot, mode: str) -> None:
190
191
  Replaces the three inline mutation sites: Shift-Tab `_cycle`, the /mode
191
192
  command action, and the remote `_on_mode` flip (including
192
193
  `_confirm_autopilot`'s approval branch). Module-level so tests drive it
193
- directly, same testing philosophy as `_slot_ctx`/`_live_session_id`."""
194
+ directly, same testing philosophy as `_slot_ctx`/`_gate_busy`."""
194
195
  slot.mode = mode
195
196
  if slot.agent is not None:
196
197
  slot.agent.mode = mode
@@ -198,31 +199,58 @@ def set_slot_mode(slot: SessionSlot, mode: str) -> None:
198
199
  save_mode(slot.workspace, mode)
199
200
 
200
201
 
201
- def _live_session_id(slots: SlotManager) -> str:
202
- """The idle-steer poller's live-session seam (map §1): the ACTIVE slot's
203
- agent session id, or "" with no crash when the active slot has no agent
204
- at all (Home)."""
205
- agent = slots.active().agent
206
- return getattr(agent, "session_id", "") if agent is not None else ""
207
-
208
-
209
- def _steer_target(slots: SlotManager) -> SessionSlot | None:
210
- """Steer-poller targeting (Task 7, map contract item 1): a remote
211
- instruction must land in a SESSION slot — Home has no sink/agent to run
212
- a turn against, so routing a steer submit there would crash. The ACTIVE
213
- slot already IS a session -> itself, exactly today's behavior. Home
214
- active -> the FIRST session slot (lowest index) -- "remote steer must
215
- never dead-end" (brief). No session slot exists at all (every tab closed
216
- down to bare Home) -> None; both `_poller_busy` and `_steer_submit` treat
217
- that as "nothing to submit into" rather than reaching for a None sink.
218
- Module-level so a test drives it directly, same as `_live_session_id`."""
219
- active = slots.active()
220
- if active.kind == "session":
221
- return active
222
- for slot in slots.slots:
223
- if slot.kind == "session":
224
- return slot
225
- return None
202
+ def _on_mode(slot: SessionSlot, mode: str, surface: str) -> None:
203
+ """Remote coding-mode request (TG/panel gateway one-shot req_mode
204
+ the pending-steer poll). AUTOPILOT SAFE ASYMMETRY (Valentin-chosen): a
205
+ downgrade or lateral move (→ default/plan) applies INSTANTLY with a
206
+ visible audited note; the upgrade → autopilot NEVER applies silently —
207
+ a terminal-local y/n confirm must approve it (the person physically at
208
+ the terminal is the risk bearer; a remote surface must not disarm the
209
+ consent prompt it is about to exploit). Unknown modes and no-ops are
210
+ dropped. Sync + non-blocking by contract (the poller calls it): the
211
+ confirm runs as its own background task.
212
+
213
+ W4b T5: `slot` is now the EXPLICIT slot the calling poller is bound to
214
+ (every session slot has its own poller see `_spawn_slot_poller`), not
215
+ a shared `first_session_slot` nonlocal module-level so a test drives
216
+ it directly, same testing philosophy as `set_slot_mode`/`_say`. Fixes
217
+ the SAME class of bug the old single-poller design had with land-on-
218
+ Home (that slot was routinely the sink-less Home tab) by construction:
219
+ a per-slot poller can only ever be bound to an actual session slot."""
220
+ surface = surface or "remote"
221
+ if slot is None or mode not in _MODES or mode == slot.mode:
222
+ return
223
+ if mode != "autopilot":
224
+ set_slot_mode(slot, mode)
225
+ _say(slot, f"mode → {mode} [{surface}]")
226
+ return
227
+ asyncio.ensure_future(_confirm_autopilot(slot, surface))
228
+
229
+
230
+ async def _confirm_autopilot(slot: SessionSlot, surface: str) -> None:
231
+ """The terminal-local one-tap confirm for a remote autopilot upgrade.
232
+ Fail-safe in every direction: no confirm affordance, a turn/prompt
233
+ already live, anything but an explicit local yes, or the prompt timeout
234
+ all KEEP the current mode — and both outcomes leave an audited note in
235
+ the transcript. While the prompt is armed the OWNING poller holds off
236
+ (`_gate_busy` on this same slot gates it), so it can never collide with
237
+ a real kernel consent (those only exist mid-turn, when the poller does
238
+ not fetch at all). `slot` is the EXPLICIT slot `_on_mode` was bound to
239
+ (W4b T5) — the confirm lands in that same slot's own pane, never
240
+ whatever tab happens to be visible."""
241
+ if slot is None:
242
+ return
243
+ ask = getattr(slot.sink, "ask_yes_no", None)
244
+ if ask is None or _gate_busy(slot.sink, slot.turn):
245
+ _say(slot, f"autopilot request from {surface} not applied — mode stays {slot.mode}")
246
+ return
247
+ ok = await ask(f"{surface} asks to switch to autopilot "
248
+ f"(auto-approve everything) — allow? [y/n]")
249
+ if ok:
250
+ set_slot_mode(slot, "autopilot")
251
+ _say(slot, f"mode → autopilot [{surface}] — approved at this terminal")
252
+ else:
253
+ _say(slot, f"autopilot request from {surface} declined — mode stays {slot.mode}")
226
254
 
227
255
 
228
256
  def _exit_dump(slots: SlotManager) -> str:
@@ -247,10 +275,13 @@ def _exit_dump(slots: SlotManager) -> str:
247
275
 
248
276
 
249
277
  def _cancel_all_background(steer_task, slots: SlotManager, resources: WorkspaceResources) -> None:
250
- """Exit-time teardown, final shape (Task 7, map contract item 4): the ONE
251
- process-wide steer poller + EVERY slot's own bg_tasks (today only Home's
252
- fill_home, map §1 -- any later per-slot background piece sweeps here too,
253
- for free) + every distinct-repo-root WorkspaceResources bundle's
278
+ """Exit-time teardown, final shape (Task 7, map contract item 4): an
279
+ optional standalone `steer_task` (kept for generality/back-compat --
280
+ W4b T5 gave every session slot its OWN idle-steer poller, folded into
281
+ that same slot's `bg_tasks` below, so run_repl itself always passes
282
+ `None` here now) + EVERY slot's own bg_tasks (Home's fill_home, every
283
+ session slot's poller, any later per-slot background piece -- all swept
284
+ here for free) + every distinct-repo-root WorkspaceResources bundle's
254
285
  watcher_task -- via the PUBLIC `resources.bundles()` accessor (never the
255
286
  private `_by_root` dict this file used to poke directly). `.done()`-
256
287
  guarded throughout (same discipline as `_cancel_slot`): an already-
@@ -351,7 +382,7 @@ async def _note_reattach(cfg, token_provider, workspace, sink) -> None:
351
382
 
352
383
  async def _finish_slot(cfg, token_provider, workspace, mode, *, resources: WorkspaceResources,
353
384
  agent_factory, intel_factory, shadow_factory, pane, sink, first: bool,
354
- account) -> "tuple[SessionSlot, int]":
385
+ account, slot_id: str = "", label: "str | None" = None) -> "tuple[SessionSlot, int]":
355
386
  """Shared tail of slot construction -- the dock's `_make_session_slot` AND
356
387
  the headless fallback loop both fall into this once they have their own
357
388
  pane (or None, fallback has no dock) + sink: resolve/boot the per-
@@ -362,13 +393,25 @@ async def _finish_slot(cfg, token_provider, workspace, mode, *, resources: Works
362
393
  session slot the process ever creates does any of that; every later tab
363
394
  (first=False) skips all three. Returns `(slot, replayed)` (FIX7e) --
364
395
  `replayed` is `boot.replay_thread`'s own display-message count (0 when
365
- `first=False`, or the replay itself skipped/failed/found nothing)."""
396
+ `first=False`, or the replay itself skipped/failed/found nothing).
397
+
398
+ `slot_id` (W4b T5) rides onto the SessionSlot AND the agent (post-
399
+ construction attribute -- works whether `agent_factory` is the DEFAULT
400
+ one or a caller-supplied test double, since neither has to know about
401
+ slot_id to still carry it correctly). `label` (T5 item 4, auto-worktree)
402
+ overrides the tab title computed from `workspace` -- a slot isolated
403
+ into its own worktree directory must still show the ORIGINAL repo's
404
+ name, not the worktree cache path's basename; omitted (every existing
405
+ caller) keeps today's `basename(workspace)` derivation exactly."""
366
406
  bundle = await _resources_bundle(cfg, workspace, resources, intel_factory, shadow_factory)
367
407
  factory = _resolve_agent_factory(agent_factory, bundle)
368
408
  agent = factory(cfg, token_provider, workspace, mode)
369
- label = os.path.basename(os.path.normpath(workspace)) or workspace
409
+ agent.slot_id = slot_id
410
+ if label is None:
411
+ label = os.path.basename(os.path.normpath(workspace)) or workspace
370
412
  slot = SessionSlot(kind="session", workspace=workspace, label=label, pane=pane,
371
- sink=sink, agent=agent, mode=mode, git_branch=bundle["git_branch"])
413
+ sink=sink, agent=agent, mode=mode, git_branch=bundle["git_branch"],
414
+ slot_id=slot_id)
372
415
  # Queue-panel single-source dedup (0.3.16): hand the sink the SAME
373
416
  # type-ahead deque tui mutates for THIS slot, so a kernel task_queued
374
417
  # echo can promote a landed local twin (matched by steer_iid) into the
@@ -391,9 +434,37 @@ async def _finish_slot(cfg, token_provider, workspace, mode, *, resources: Works
391
434
  return slot, replayed
392
435
 
393
436
 
437
+ async def _isolate_workspace(workspace: str, resources: WorkspaceResources, *,
438
+ first: bool, slot_id: str) -> "tuple[str, str]":
439
+ """W4b T5 item 4 — auto-worktree for a same-repo SECOND tab. `first=True`
440
+ (tab 1 — nothing can be "already open" yet) always skips this outright.
441
+ Otherwise: `resources.get(workspace)` is non-None iff some OTHER session
442
+ slot already booted a WorkspaceResources bundle for this exact repo root
443
+ (booted ONLY by `_finish_slot`, i.e. only by an actual SESSION slot, never
444
+ Home) -- the recon's "an EXISTING session slot's repo root" check, for
445
+ free, with no need to walk the live SlotManager at all. A worktree's OWN
446
+ root is a DIFFERENT realpath (its own `.git` file), so a worktree is
447
+ never mistaken for "the same repo" a second time.
448
+
449
+ Returns `(effective_workspace, note)` -- `effective_workspace` is the new
450
+ worktree path on success, or `workspace` UNCHANGED on a skip/failure;
451
+ `note` is the honest one-line status for the slot's own sink, or "" when
452
+ nothing happened (first tab, or a different repo)."""
453
+ if first or resources.get(workspace) is None:
454
+ return workspace, ""
455
+ from webbee.repo import find_repo_root
456
+ from webbee.worktrees import create_worktree
457
+ root = await asyncio.to_thread(find_repo_root, workspace)
458
+ wt_path = await asyncio.to_thread(create_worktree, root, slot_id or "0")
459
+ if wt_path:
460
+ return wt_path, "⎇ isolated worktree — your edits land here; the main checkout is untouched"
461
+ return workspace, "⚠ shared checkout — parallel edits may conflict"
462
+
463
+
394
464
  async def _make_session_slot(cfg, token_provider, workspace, mode, *, resources: WorkspaceResources,
395
465
  shared_client, agent_factory, intel_factory, shadow_factory,
396
- first: bool, account=None, _with_replayed: bool = False):
466
+ first: bool, account=None, _with_replayed: bool = False,
467
+ slot_id: "str | None" = None):
397
468
  """Builds ONE dock tab's atomic {agent, sink, pane} triple (map §6 —
398
469
  created together, a sink must never point at another slot's pane/
399
470
  console). `shared_client` isn't consumed here yet — reserved for Task 3's
@@ -409,20 +480,39 @@ async def _make_session_slot(cfg, token_provider, workspace, mode, *, resources:
409
480
  `set_slot_mode`) overrides it here, off-loop (a git subprocess sits
410
481
  behind it). Never autopilot: `save_mode` itself downgrades that to
411
482
  'default' before it's ever written, so a loaded mode is always safe to
412
- resume silently."""
483
+ resume silently.
484
+
485
+ W4b T5: `slot_id=None` (every existing caller) auto-mints a short hex id
486
+ for every tab EXCEPT the very first (`first=True` keeps "" — the legacy
487
+ id, preserving reattach-to-parked semantics); pass an explicit value only
488
+ for deterministic tests. When a SECOND tab targets a repo root some other
489
+ slot already has open, its workspace is auto-isolated into its own `git
490
+ worktree` (`_isolate_workspace`) -- the tab's LABEL still reflects the
491
+ original repo name (computed here, before the swap), never the worktree
492
+ cache path's basename."""
493
+ from uuid import uuid4
494
+
413
495
  from webbee import tui
414
496
  from webbee.mode_store import load_mode
415
497
  from webbee.render import RichSink
416
498
  from webbee.sizing import get_size
417
499
 
500
+ if slot_id is None:
501
+ slot_id = "" if first else uuid4().hex[:6]
418
502
  effective_mode = await asyncio.to_thread(load_mode, workspace) or mode
419
503
  width, _height = get_size(None) # pre-app: same fallback tui.run_session's own sizing uses
420
504
  pane = tui.OutputPane(width=width)
421
505
  sink = RichSink(console=pane.console, on_output=pane.notify)
422
- slot, replayed = await _finish_slot(cfg, token_provider, workspace, effective_mode, resources=resources,
423
- agent_factory=agent_factory, intel_factory=intel_factory,
424
- shadow_factory=shadow_factory, pane=pane, sink=sink,
425
- first=first, account=account)
506
+ label = os.path.basename(os.path.normpath(workspace)) or workspace
507
+ effective_workspace, wt_note = await _isolate_workspace(
508
+ workspace, resources, first=first, slot_id=slot_id)
509
+ slot, replayed = await _finish_slot(cfg, token_provider, effective_workspace, effective_mode,
510
+ resources=resources, agent_factory=agent_factory,
511
+ intel_factory=intel_factory, shadow_factory=shadow_factory,
512
+ pane=pane, sink=sink, first=first, account=account,
513
+ slot_id=slot_id, label=label)
514
+ if wt_note:
515
+ sink.note(wt_note)
426
516
  return (slot, replayed) if _with_replayed else slot
427
517
 
428
518
 
@@ -443,7 +533,7 @@ def _home_target_workspace(slots: SlotManager, cwd: str) -> str:
443
533
  async def _home_input(text: str, *, slots: SlotManager, cfg, token_provider, mode: str,
444
534
  resources: WorkspaceResources, shared_client, agent_factory,
445
535
  intel_factory, shadow_factory, workspace: str,
446
- ui_hooks: dict, run_turn) -> None:
536
+ ui_hooks: dict, run_turn, spawn_poller=None) -> None:
447
537
  """Home's Enter path (Task 6, wired into `tui.run_session` as
448
538
  `home_input=`): typing a task on Home opens a session tab in one motion
449
539
  — the SAME `_make_session_slot`/switch path the `/new` command uses
@@ -465,7 +555,15 @@ async def _home_input(text: str, *, slots: SlotManager, cfg, token_provider, mod
465
555
  the deliberate action here: announcing it too would just repeat the ❯
466
556
  echo that follows immediately after. Module-level + fully parameterized
467
557
  (not a closure) so a test can drive it directly, same testing philosophy
468
- as `_finish_slot`/`_make_session_slot`."""
558
+ as `_finish_slot`/`_make_session_slot`.
559
+
560
+ `spawn_poller` (W4b T5, optional) — run_repl's own per-slot idle-steer
561
+ poller starter (`_spawn_slot_poller`), a SEPARATE param rather than
562
+ threaded into the `_make_session_slot` call above: that call's signature
563
+ is depended on verbatim by several tests that stand in a bare fake for
564
+ the whole function, so a new REQUIRED kwarg there would break them for
565
+ no benefit -- None (those same tests, and every direct caller that
566
+ doesn't care) is a plain no-op."""
469
567
  ws = _home_target_workspace(slots, workspace)
470
568
  new_slot = await _make_session_slot(
471
569
  cfg, token_provider, ws, mode, resources=resources,
@@ -473,6 +571,8 @@ async def _home_input(text: str, *, slots: SlotManager, cfg, token_provider, mod
473
571
  intel_factory=intel_factory, shadow_factory=shadow_factory, first=False)
474
572
  idx = slots.add(new_slot)
475
573
  ui_hooks.get("switch", slots.switch)(idx)
574
+ if spawn_poller is not None:
575
+ spawn_poller(new_slot)
476
576
  new_slot.sink.user_echo(text)
477
577
  start_turn_in = ui_hooks.get("start_turn_in")
478
578
  if start_turn_in is not None:
@@ -555,16 +655,14 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
555
655
  state = {"logged_in": False}
556
656
  slots = SlotManager()
557
657
  resources = WorkspaceResources() # per-workspace boot cache (map §6)
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
658
+ # W4b T5: EVERY session slot gets its OWN idle-steer poller now (was: one
659
+ # process-wide poller chasing a shared `first_session_slot`) -- each
660
+ # poller lives in its own slot's `bg_tasks`, cancelled by the ordinary
661
+ # slot-close/exit teardown with no new plumbing (see `_spawn_slot_poller`
662
+ # below). `_poller_seq` is the stagger counter (item 3): each new
663
+ # poller's FIRST tick carries a small incrementing offset so several tabs
664
+ # opened back-to-back don't all hit the gateway in the same instant.
665
+ _poller_seq = 0
568
666
  # Task 5 (map contract item 5): the dock fills this at construction time
569
667
  # with `switch`/`close` -- the SAME `_switch_to`/`_close_flow` a click or
570
668
  # a key goes through (history swap, close note) -- so /tab, /new and
@@ -741,6 +839,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
741
839
  first=False)
742
840
  idx = slots.add(new_slot)
743
841
  ui_hooks.get("switch", slots.switch)(idx)
842
+ _spawn_slot_poller(new_slot)
744
843
  new_slot.sink.note(f"tab {idx} opened — {new_slot.label}")
745
844
  return "continue"
746
845
  if res.action == "tab_switch":
@@ -773,7 +872,12 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
773
872
  # gated) -- unconditional _say instead of the old
774
873
  # `if _sink is not None:` guard, which silently swallowed
775
874
  # the listing instead of showing it.
875
+ # W4b T5 item 5: the tab BAR stays clean (no slot_id there,
876
+ # ever) -- this verbose /tabs note is the one place a tab's
877
+ # own short slot_id shows, so it's discoverable without
878
+ # cluttering the always-visible bar.
776
879
  lines = [f"{'●' if i == slots.active_idx else '○'}{i} {s.label} {s.status_glyph()}"
880
+ f"{' ·s' + s.slot_id if getattr(s, 'slot_id', '') else ''}"
777
881
  for i, s in enumerate(slots.slots)]
778
882
  _say(slot, "Open tabs:\n" + "\n".join(lines))
779
883
  return "continue"
@@ -812,9 +916,10 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
812
916
  async def _run_turn_on(slot: SessionSlot, line: str, surface: str = "", steer_iid: str = "") -> None:
813
917
  """ONE agent turn against an EXPLICIT slot (Task 7 split -- was:
814
918
  always `slots.active()` internally, which broke a steer submit that
815
- deliberately targets a DIFFERENT slot than whatever's on screen, see
816
- `_steer_target`). `_run_turn` below is the thin active()-resolving
817
- wrapper every typed-line call site keeps using unchanged."""
919
+ deliberately targets a DIFFERENT slot than whatever's on screen --
920
+ W4b T5's `_steer_submit_on` is the poller's own caller now, each
921
+ bound to its OWN slot). `_run_turn` below is the thin active()-
922
+ resolving wrapper every typed-line call site keeps using unchanged."""
818
923
  _sink = slot.sink
819
924
  _sink.begin_turn()
820
925
  kw = {"surface": surface} if surface else {}
@@ -853,44 +958,25 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
853
958
  original design). The caller (`_handle`, `_home_input`) always hands
854
959
  the slot the line actually belongs to, pinned at the moment the turn
855
960
  started -- never whatever tab happens to be active when this
856
- coroutine's body actually runs. `_steer_submit` below bypasses this
857
- entirely and calls `_run_turn_on` directly against `_steer_target`'s
858
- pick, which is NOT always the active slot (Home active -> the first
859
- session slot)."""
961
+ coroutine's body actually runs. `_steer_submit_on` below bypasses
962
+ this entirely and calls `_run_turn_on` directly against a specific
963
+ poller's OWN bound slot (W4b T5 -- every session slot has its own
964
+ poller now, so there's no more Home-routing ambiguity to resolve)."""
860
965
  await _run_turn_on(slot, line, surface=surface, steer_iid=steer_iid)
861
966
 
862
- async def _steer_submit(text: str, surface: str, steer_iid: str = "") -> None:
967
+ async def _steer_submit_on(slot: SessionSlot, text: str, surface: str,
968
+ steer_iid: str = "") -> None:
863
969
  """webbee.steer hands a drained remote instruction here: render it as
864
970
  the remote user's own line, then run it as a normal turn (carrying the
865
- item's dedup iid so the kernel can drop an at-least-once twin).
866
- Targets `_steer_target(slots)` (Task 7, map contract item 1) -- a
867
- SESSION slot, never Home: the active slot when it's already a
868
- session, otherwise the first session slot, so a remote instruction
869
- never dead-ends just because Home happens to be on screen. No session
870
- slot exists at all (every tab closed) -> fail-soft, sink-less
871
- no-op -- the poller's own contract is silence on nothing-to-do, not a
872
- crash or a note nobody would see anyway."""
873
- slot = _steer_target(slots)
874
- if slot is None:
875
- return
971
+ item's dedup iid so the kernel can drop an at-least-once twin). W4b
972
+ T5: `slot` is EXPLICIT now -- each per-slot poller (`_spawn_slot_
973
+ poller`) closes over its OWN slot, so there's no more "which session
974
+ slot should this remote instruction land in" question to answer
975
+ (was: `_steer_target(slots)`'s active-or-first-session guess, needed
976
+ only because one process-wide poller had to pick SOME slot)."""
876
977
  slot.sink.foreign_turn(surface, "user", text)
877
978
  await _run_turn_on(slot, text, surface=surface, steer_iid=steer_iid)
878
979
 
879
- def _poller_busy() -> bool:
880
- """The idle-steer poller's busy gate: the TARGET slot's (Task 7 --
881
- was: blindly the active slot) live turn state (LOCKOUT-PROOF via
882
- _gate_busy -- a dead turn task overrides a stuck busy flag) PLUS an
883
- armed local prompt (the autopilot confirm arms the same pinned-input
884
- future a consent uses) -- submitting a steer turn under an armed
885
- prompt could double-prompt the input, so the poller holds off until
886
- it resolves. No session slot to target at all -> treat as busy (hold
887
- the poller) rather than fetch into a void `_steer_submit` would just
888
- drop. Both hooks getattr-guarded (minimal test sinks)."""
889
- slot = _steer_target(slots)
890
- if slot is None:
891
- return True
892
- return _gate_busy(slot.sink, slot.turn)
893
-
894
980
  async def _stop_active_turn() -> None:
895
981
  """tui.run_session's `stop_turn` leg (Esc/Ctrl-C) -- resolves the
896
982
  ACTIVE slot's agent AT CALL TIME (W4a Task 3: the injected callable
@@ -901,87 +987,46 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
901
987
  if agent is not None:
902
988
  await agent.stop()
903
989
 
904
- def _on_mode(mode: str, surface: str) -> None:
905
- """Remote coding-mode request (TG/panel → gateway one-shot req_mode →
906
- the pending-steer poll). AUTOPILOT SAFE ASYMMETRY (Valentin-chosen):
907
- a downgrade or lateral move (→ default/plan) applies INSTANTLY with a
908
- visible audited note; the upgrade → autopilot NEVER applies silently —
909
- a terminal-local y/n confirm must approve it (the person physically
910
- at the terminal is the risk bearer; a remote surface must not disarm
911
- the consent prompt it is about to exploit). Unknown modes and no-ops
912
- are dropped. Sync + non-blocking by contract (the poller calls it):
913
- the confirm runs as its own background task. Reads the ACTIVE slot
914
- at call time (map §6 steer policy)."""
915
- surface = surface or "remote"
916
- slot = slots.active()
917
- if mode not in _MODES or mode == slot.mode:
918
- return
919
- if mode != "autopilot":
920
- set_slot_mode(slot, mode)
921
- slot.sink.note(f"mode → {mode} [{surface}]")
922
- return
923
- asyncio.ensure_future(_confirm_autopilot(surface))
924
-
925
- async def _confirm_autopilot(surface: str) -> None:
926
- """The terminal-local one-tap confirm for a remote autopilot upgrade.
927
- Fail-safe in every direction: no confirm affordance, a turn/prompt
928
- already live, anything but an explicit local yes, or the prompt
929
- timeout all KEEP the current mode — and both outcomes leave an
930
- audited note in the transcript. While the prompt is armed the steer
931
- poller holds off (_poller_busy gates it), so it can never collide
932
- with a real kernel consent (those only exist mid-turn, when the
933
- poller does not fetch at all). Reads the ACTIVE slot once at call
934
- time (map §6) -- there is no tab-switch UI yet, so this always
935
- targets the slot the request was raised against."""
936
- slot = slots.active()
937
- ask = getattr(slot.sink, "ask_yes_no", None)
938
- if ask is None or _poller_busy():
939
- slot.sink.note(f"autopilot request from {surface} not applied — mode stays {slot.mode}")
940
- return
941
- ok = await ask(f"{surface} asks to switch to autopilot "
942
- f"(auto-approve everything) — allow? [y/n]")
943
- if ok:
944
- set_slot_mode(slot, "autopilot")
945
- slot.sink.note(f"mode → autopilot [{surface}] — approved at this terminal")
946
- else:
947
- slot.sink.note(f"autopilot request from {surface} declined — mode stays {slot.mode}")
948
-
949
990
  def _cancel_background() -> None:
950
991
  # Task 7: the actual walk moved to the module-level, directly-tested
951
- # `_cancel_all_background` (steer + every slot's bg_tasks, .done()-
952
- # guarded + every WorkspaceResources bundle's watcher_task via the
953
- # PUBLIC `resources.bundles()`, never `_by_root` poked directly).
954
- _cancel_all_background(steer_task, slots, resources)
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
-
966
- def _spawn_steer() -> None:
967
- nonlocal steer_task
992
+ # `_cancel_all_background` (every slot's bg_tasks, .done()-guarded --
993
+ # W4b T5 folded the steer poller(s) into that same per-slot list, so
994
+ # there's no separate global steer_task to pass anymore -- + every
995
+ # WorkspaceResources bundle's watcher_task via the PUBLIC
996
+ # `resources.bundles()`, never `_by_root` poked directly).
997
+ _cancel_all_background(None, slots, resources)
998
+
999
+ def _spawn_slot_poller(slot: SessionSlot) -> None:
1000
+ """W4b T5: EVERY session slot gets its OWN idle-steer poller against
1001
+ ITS OWN gateway session id -- the gateway now keys pending_steer per
1002
+ session (T2), so N tabs are genuinely independent remote-steerable
1003
+ sessions instead of one process-wide poller chasing whichever tab
1004
+ happened to be active (W4a's model, which starved every OTHER tab).
1005
+ Targets THIS slot exclusively, never `slots.active()`, never a
1006
+ shared `first_session_slot`: `is_busy`/`mode_getter` read its OWN
1007
+ sink/turn/mode, `live_session_id` its OWN agent, `submit` renders the
1008
+ remote line into its OWN pane and runs the turn through
1009
+ `_run_turn_on(slot, ...)`. Staggered (item 3): each poller's FIRST
1010
+ tick carries a small incrementing offset (0, 1, 2, 0, 1, 2... seconds
1011
+ -- `poll_idle_steer`'s own `initial_delay`) so several tabs opened
1012
+ back-to-back don't all hit the gateway in the same instant. Appended
1013
+ to `slot.bg_tasks` -- the ordinary slot-close/exit teardown
1014
+ (`_cancel_slot`/`_cancel_all_background`) already walks every slot's
1015
+ own bg_tasks, so cancellation needs no new plumbing at all."""
1016
+ nonlocal _poller_seq
1017
+ offset = float(_poller_seq % 3)
1018
+ _poller_seq += 1
968
1019
  from webbee import steer as _steer
969
- # Liveness v2 §B: idle-steer pickup. All poll/drain logic lives in
970
- # webbee.steer -- this is wiring only: the ACTIVE slot's live turn
971
- # state (+ an armed local prompt) gates polling, the ACTIVE slot's
972
- # agent session id (once a turn has run) is the gateway truth,
973
- # _steer_submit is the normal turn path, and _on_mode adopts a
974
- # remote mode request (autopilot safe-asymmetry). ONE poller for the
975
- # whole process (map §6): `workspace` stays bound to the FIRST
976
- # session slot's repo root -- today's `workspace` local, since W4a
977
- # never opens a second tab on a different repo. pending_steer is per-
978
- # USER gateway-side; W4b re-keys it per session instead.
979
- steer_task = asyncio.ensure_future(_steer.poll_idle_steer(
980
- cfg, token_provider, workspace=workspace, marathon=not once,
981
- is_busy=_poller_busy,
982
- live_session_id=lambda: _live_session_id(slots),
983
- submit=_steer_submit, on_mode=_on_mode, mode_getter=_mode_getter,
984
- client=shared_client))
1020
+ task = asyncio.ensure_future(_steer.poll_idle_steer(
1021
+ cfg, token_provider, workspace=slot.workspace, marathon=not once,
1022
+ is_busy=lambda: _gate_busy(slot.sink, slot.turn),
1023
+ live_session_id=lambda: getattr(slot.agent, "session_id", "") or "",
1024
+ submit=lambda text, surface, steer_iid="": _steer_submit_on(
1025
+ slot, text, surface, steer_iid),
1026
+ on_mode=lambda mode, surface: _on_mode(slot, mode, surface),
1027
+ mode_getter=lambda: slot.mode,
1028
+ client=shared_client, slot_id=slot.slot_id, initial_delay=offset))
1029
+ slot.bg_tasks.append(task)
985
1030
 
986
1031
  if use_dock:
987
1032
  ok = False
@@ -1009,13 +1054,12 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1009
1054
  intel_factory=intel_factory, shadow_factory=shadow_factory,
1010
1055
  first=True, account=account, _with_replayed=True)
1011
1056
  slots.add(session_slot)
1012
- first_session_slot = session_slot # T6.2: what the poller's mode_getter reads
1013
1057
  # FIX7e (W4a final review — land-on-Home): the session tab
1014
1058
  # is only the more useful FIRST screen when the boot replay
1015
1059
  # actually showed something -- a fresh/empty thread lands on
1016
1060
  # Home instead (the new-tab dashboard), one Alt+1 away.
1017
1061
  slots.active_idx = 1 if replayed else 0
1018
- _spawn_steer()
1062
+ _spawn_slot_poller(session_slot)
1019
1063
  # Task 6: fill Home in the background from the moment it
1020
1064
  # exists -- never blocks reaching the session tab above, and
1021
1065
  # is very likely already done (or well under way) by the
@@ -1044,7 +1088,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1044
1088
  text, slots=slots, cfg=cfg, token_provider=token_provider, mode=mode,
1045
1089
  resources=resources, shared_client=shared_client, agent_factory=agent_factory,
1046
1090
  intel_factory=intel_factory, shadow_factory=shadow_factory,
1047
- workspace=workspace, ui_hooks=ui_hooks, run_turn=_run_turn),
1091
+ workspace=workspace, ui_hooks=ui_hooks, run_turn=_run_turn,
1092
+ spawn_poller=_spawn_slot_poller),
1048
1093
  on_switch=lambda idx: _schedule_home_refill(slots, idx, home_fill_kwargs),
1049
1094
  )
1050
1095
  finally:
@@ -1093,8 +1138,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1093
1138
  shadow_factory=shadow_factory, pane=None, sink=sink,
1094
1139
  first=True, account=account)
1095
1140
  slots.add(fallback_slot)
1096
- first_session_slot = fallback_slot # T6.2: what the poller's mode_getter reads
1097
- _spawn_steer()
1141
+ _spawn_slot_poller(fallback_slot)
1098
1142
  try:
1099
1143
  while True:
1100
1144
  try: