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.
- {webbee-0.3.20 → webbee-0.3.22}/CHANGELOG.md +23 -0
- {webbee-0.3.20 → webbee-0.3.22}/PKG-INFO +1 -1
- {webbee-0.3.20 → webbee-0.3.22}/pyproject.toml +1 -1
- webbee-0.3.22/src/webbee/__init__.py +1 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/repl.py +216 -172
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/session.py +12 -1
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/slots.py +5 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/steer.py +31 -4
- webbee-0.3.22/src/webbee/worktrees.py +73 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_repl.py +354 -70
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_session.py +35 -2
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_steer.py +88 -0
- webbee-0.3.22/tests/test_worktrees.py +119 -0
- webbee-0.3.20/src/webbee/__init__.py +0 -1
- {webbee-0.3.20 → webbee-0.3.22}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/.gitignore +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/LICENSE +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/README.md +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/install.sh +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/account.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/active_sessions.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/boot.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/cli.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/commands.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/config.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/consent.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/details.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/events.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/frames.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/home.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/http.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/mode_store.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/queue_panel.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/reflow.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/remote.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/render.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/repo.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/selection.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/sessions.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/sizing.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/stream.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/tabs.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/thread.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/todo_panel.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/tokens.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/tools.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/tui.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/src/webbee/update.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/__init__.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/conftest.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_account.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_active_sessions.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_cli.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_clipboard.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_commands.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_config.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_details.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_events.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_home.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_query.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_service.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_store.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_marathon.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_mode_store.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_packaging.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_reflow.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_render.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_repo.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_sessions.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_sizing.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_slots.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_stream.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_tabs.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_thread.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_todo_panel.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_tokens.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_tools.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_tui.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.20 → webbee-0.3.22}/tests/test_update.py +0 -0
- {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),
|
|
@@ -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
|
|
109
|
-
|
|
110
|
-
|
|
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`/`
|
|
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
|
|
202
|
-
"""
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
that
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if
|
|
221
|
-
return
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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):
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
559
|
-
#
|
|
560
|
-
#
|
|
561
|
-
#
|
|
562
|
-
#
|
|
563
|
-
#
|
|
564
|
-
#
|
|
565
|
-
|
|
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
|
|
816
|
-
|
|
817
|
-
|
|
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. `
|
|
857
|
-
entirely and calls `_run_turn_on` directly against
|
|
858
|
-
|
|
859
|
-
|
|
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
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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` (
|
|
952
|
-
#
|
|
953
|
-
#
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1097
|
-
_spawn_steer()
|
|
1141
|
+
_spawn_slot_poller(fallback_slot)
|
|
1098
1142
|
try:
|
|
1099
1143
|
while True:
|
|
1100
1144
|
try:
|