webbee 0.3.23__tar.gz → 0.3.25__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.23 → webbee-0.3.25}/CHANGELOG.md +40 -0
- {webbee-0.3.23 → webbee-0.3.25}/PKG-INFO +1 -1
- {webbee-0.3.23 → webbee-0.3.25}/pyproject.toml +1 -1
- webbee-0.3.25/src/webbee/__init__.py +1 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/commands.py +5 -0
- webbee-0.3.25/src/webbee/instance_lock.py +83 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/repl.py +124 -30
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/slots.py +101 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/steer.py +14 -0
- webbee-0.3.25/src/webbee/tabs.py +191 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/thread.py +15 -2
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/tui.py +153 -34
- {webbee-0.3.23 → webbee-0.3.25}/tests/conftest.py +11 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_commands.py +16 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_home.py +46 -0
- webbee-0.3.25/tests/test_instance_lock.py +89 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_repl.py +183 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_slots.py +130 -1
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_steer.py +109 -0
- webbee-0.3.25/tests/test_tabs.py +494 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_thread.py +115 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_tui.py +495 -27
- webbee-0.3.23/src/webbee/__init__.py +0 -1
- webbee-0.3.23/src/webbee/tabs.py +0 -125
- webbee-0.3.23/tests/test_tabs.py +0 -280
- {webbee-0.3.23 → webbee-0.3.25}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/.gitignore +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/LICENSE +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/README.md +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/install.sh +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/account.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/active_sessions.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/boot.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/cli.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/config.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/consent.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/details.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/events.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/frames.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/home.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/http.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/mode_store.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/queue_panel.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/reflow.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/remote.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/render.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/repo.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/selection.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/session.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/sessions.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/sizing.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/stream.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/todo_panel.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/tokens.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/tools.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/update.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/src/webbee/worktrees.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/__init__.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_account.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_active_sessions.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_cli.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_clipboard.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_config.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_details.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_events.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_intel_query.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_intel_service.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_intel_store.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_marathon.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_mode_store.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_packaging.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_reflow.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_render.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_repo.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_session.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_sessions.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_sizing.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_stream.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_todo_panel.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_tokens.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_tools.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_update.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_version.py +0 -0
- {webbee-0.3.23 → webbee-0.3.25}/tests/test_worktrees.py +0 -0
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.25
|
|
4
|
+
|
|
5
|
+
Tabs name themselves after your first message, a + button opens a tab like
|
|
6
|
+
a browser, the tab bar got breathing room and a proper bar background, and
|
|
7
|
+
stray terminal codes can't pollute the input anymore.
|
|
8
|
+
|
|
9
|
+
- A session tab now renames itself from your first message — like a
|
|
10
|
+
browser page title — so you can tell tabs apart at a glance instead of
|
|
11
|
+
seeing the same folder name everywhere. `/rename <name>` sets your own
|
|
12
|
+
title any time and it sticks (auto-naming never overrides it).
|
|
13
|
+
- A new + button at the end of the tab bar opens a new tab, just like a
|
|
14
|
+
browser — no more remembering the keyboard shortcut.
|
|
15
|
+
- The tab bar now sits on its own subtle bar background with a blank row of
|
|
16
|
+
breathing room below it, separating it clearly from your conversation.
|
|
17
|
+
- Clicking the ✕ or the + button now only reacts to a precise click on the
|
|
18
|
+
glyph itself — a near-miss next to it no longer accidentally closes a tab.
|
|
19
|
+
- Closing a tab that's still busy now asks you to confirm first (click ✕
|
|
20
|
+
again) — the server-side run keeps going either way, this just protects
|
|
21
|
+
against an accidental close.
|
|
22
|
+
- Opening the same project in two separate windows no longer makes them
|
|
23
|
+
freeze each other — the second window notices and starts its own
|
|
24
|
+
parallel session automatically.
|
|
25
|
+
- Stray terminal focus-tracking codes (from switching windows or tmux
|
|
26
|
+
panes) can no longer leak into what you're typing.
|
|
27
|
+
|
|
28
|
+
## 0.3.24
|
|
29
|
+
|
|
30
|
+
The tab bar got a real design: the active tab is a solid highlighted chip,
|
|
31
|
+
tabs are clearly separated, and spacing is uniform — plus a message typed
|
|
32
|
+
on Home no longer shows up twice in the new tab.
|
|
33
|
+
|
|
34
|
+
- Tabs now render as padded chips with uniform spacing, a clear dim
|
|
35
|
+
separator between every pair, and the active tab as a solid
|
|
36
|
+
bee-yellow block — unmistakable at a glance, no more squinting to find
|
|
37
|
+
which tab you're on.
|
|
38
|
+
- Typing a task on Home and landing in the new session tab no longer
|
|
39
|
+
double-prints your first message in that tab's transcript.
|
|
40
|
+
- Each tab now keeps its own input draft — switch away and back, your
|
|
41
|
+
unsent text is right where you left it.
|
|
42
|
+
|
|
3
43
|
## 0.3.23
|
|
4
44
|
|
|
5
45
|
If a task on your session is waiting for your terminal — sent from the panel
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.25"
|
|
@@ -14,6 +14,7 @@ _HELP = """Commands:
|
|
|
14
14
|
/tab N switch to tab N (see /tabs for the numbers)
|
|
15
15
|
/close close the active tab (the run keeps going server-side)
|
|
16
16
|
/tabs list open tabs
|
|
17
|
+
/rename <name> rename the active tab (sticks -- auto-naming won't override it)
|
|
17
18
|
/queue [clear] messages queued while Webbee works (clear drops them all)
|
|
18
19
|
/steps [N] last turn's steps; N expands one (also: Up/Down + Enter)
|
|
19
20
|
/checkpoints the reversibility time machine — list workspace checkpoints
|
|
@@ -96,6 +97,10 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
|
|
|
96
97
|
return SlashResult(handled=True, action="tab_close")
|
|
97
98
|
if cmd == "/tabs":
|
|
98
99
|
return SlashResult(handled=True, action="tabs_list")
|
|
100
|
+
if cmd == "/rename":
|
|
101
|
+
# repl reports "Usage: /rename <name>" on an empty arg -- same split
|
|
102
|
+
# as /rollback (this file never crafts the usage message itself).
|
|
103
|
+
return SlashResult(handled=True, action="rename", arg=" ".join(args))
|
|
99
104
|
if cmd == "/queue":
|
|
100
105
|
# ctx.queued is the live deque's snapshot (threaded by the repl the
|
|
101
106
|
# same way /status reads session state); the repl clears the actual
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Per-repo single-instance advisory lock (0.3.25 Part C, second-instance
|
|
2
|
+
collision fix): today a second `webbee` launched in the SAME repo derives
|
|
3
|
+
the SAME legacy session id as the first (tab-1's `slot_id` is always ""),
|
|
4
|
+
so both processes poll/stream the IDENTICAL gateway session and starve each
|
|
5
|
+
other -- looks frozen, live-reproduced twice (Valentin, 2026-07-19/20).
|
|
6
|
+
|
|
7
|
+
`acquire(repo_key)` takes a non-blocking exclusive `flock` on
|
|
8
|
+
`~/.cache/webbee/instance-{repo_key}.lock` (same repo-identity/cache-dir
|
|
9
|
+
house pattern as `mode_store` — `webbee.repo.compute_repo_key`). The FIRST
|
|
10
|
+
process to open a given repo keeps the fd open for its whole lifetime; the
|
|
11
|
+
kernel drops the flock automatically the instant that fd closes — process
|
|
12
|
+
exit, crash, `kill -9`, all release it for free, no stale-lock cleanup ever
|
|
13
|
+
needed anywhere. A SECOND process trying the SAME repo_key finds the lock
|
|
14
|
+
already held and gets `held=True` back — `repl._make_session_slot` reads
|
|
15
|
+
that to mint a fresh `slot_id` for what would otherwise be its own tab 1,
|
|
16
|
+
exactly like every LATER tab already does, and leaves an honest transcript
|
|
17
|
+
note instead of silently starving.
|
|
18
|
+
|
|
19
|
+
Fail-soft in BOTH directions, matching `mode_store`'s posture: ANY error
|
|
20
|
+
acquiring the lock (a read-only home, a full disk, no `fcntl` on this
|
|
21
|
+
platform, a filesystem that doesn't support `flock` at all) degrades to
|
|
22
|
+
"proceed as primary" (`held=False`) — a false negative here is, at worst,
|
|
23
|
+
the very starvation this module exists to fix; it is never a reason to
|
|
24
|
+
crash the terminal."""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
|
|
29
|
+
_CACHE_DIR = os.path.expanduser("~/.cache/webbee") # test seam: monkeypatch this name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _path_for(repo_key: str) -> str:
|
|
33
|
+
return os.path.join(_CACHE_DIR, f"instance-{repo_key}.lock")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InstanceLock:
|
|
37
|
+
"""The result of one `acquire()` call. `held` is True iff SOME OTHER
|
|
38
|
+
process already owned this repo's lock at that moment (this instance is
|
|
39
|
+
SECONDARY); `close()` releases the fd (a no-op the second time, and on a
|
|
40
|
+
`held=True` result — there is nothing of this instance's own to
|
|
41
|
+
release) and never raises."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, held: bool, fd: "int | None") -> None:
|
|
44
|
+
self.held = held
|
|
45
|
+
self._fd = fd
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
if self._fd is not None:
|
|
49
|
+
try:
|
|
50
|
+
os.close(self._fd)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
self._fd = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def acquire(repo_key: str) -> InstanceLock:
|
|
57
|
+
"""Try to become the primary Webbee for `repo_key`. Never raises: any
|
|
58
|
+
failure along the way degrades to `InstanceLock(held=False, fd=None)`
|
|
59
|
+
(module docstring) — this instance simply proceeds as primary."""
|
|
60
|
+
try:
|
|
61
|
+
import fcntl
|
|
62
|
+
os.makedirs(_CACHE_DIR, exist_ok=True)
|
|
63
|
+
fd = os.open(_path_for(repo_key), os.O_CREAT | os.O_RDWR, 0o600)
|
|
64
|
+
except Exception:
|
|
65
|
+
# No fcntl at all (a non-POSIX platform), an unwritable/missing
|
|
66
|
+
# cache dir, or any other setup failure -- there is no fd to clean
|
|
67
|
+
# up, proceed as primary.
|
|
68
|
+
return InstanceLock(held=False, fd=None)
|
|
69
|
+
try:
|
|
70
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
71
|
+
except OSError:
|
|
72
|
+
# The expected "someone else already holds it" outcome
|
|
73
|
+
# (EWOULDBLOCK/EAGAIN, surfaced as BlockingIOError -- an OSError
|
|
74
|
+
# subclass) -- this instance is SECONDARY.
|
|
75
|
+
os.close(fd)
|
|
76
|
+
return InstanceLock(held=True, fd=None)
|
|
77
|
+
except Exception:
|
|
78
|
+
# Any OTHER unexpected failure locking a perfectly good fd -- same
|
|
79
|
+
# fail-soft posture, but this one really is ours to close since
|
|
80
|
+
# flock never actually succeeded on it either way.
|
|
81
|
+
os.close(fd)
|
|
82
|
+
return InstanceLock(held=False, fd=None)
|
|
83
|
+
return InstanceLock(held=False, fd=fd)
|
|
@@ -8,7 +8,8 @@ from webbee import __version__, boot, home
|
|
|
8
8
|
from webbee.account import login_device_flow
|
|
9
9
|
from webbee.commands import CommandContext, dispatch
|
|
10
10
|
from webbee.session import AgentSession
|
|
11
|
-
from webbee.slots import SessionSlot, SlotManager, WorkspaceResources,
|
|
11
|
+
from webbee.slots import (SessionSlot, SlotManager, WorkspaceResources,
|
|
12
|
+
auto_label, close_active, sanitize_label)
|
|
12
13
|
from webbee.tui import _MODES, next_mode
|
|
13
14
|
|
|
14
15
|
|
|
@@ -151,7 +152,7 @@ def _slot_ctx(slot: SessionSlot, *, logged_in: bool) -> CommandContext:
|
|
|
151
152
|
_HOME_GATED_ACTIONS = frozenset({
|
|
152
153
|
"steps", "step_detail", "checkpoints", "rollback", "notify", "mode",
|
|
153
154
|
"cost", "queue", "queue_clear", "login", "logout", "sessions",
|
|
154
|
-
"sessions_revoke", "logout_others",
|
|
155
|
+
"sessions_revoke", "logout_others", "rename",
|
|
155
156
|
})
|
|
156
157
|
|
|
157
158
|
_HOME_GATE_NOTE = "open a session tab first — Ctrl+T or type a task"
|
|
@@ -489,16 +490,45 @@ async def _make_session_slot(cfg, token_provider, workspace, mode, *, resources:
|
|
|
489
490
|
slot already has open, its workspace is auto-isolated into its own `git
|
|
490
491
|
worktree` (`_isolate_workspace`) -- the tab's LABEL still reflects the
|
|
491
492
|
original repo name (computed here, before the swap), never the worktree
|
|
492
|
-
cache path's basename.
|
|
493
|
+
cache path's basename.
|
|
494
|
+
|
|
495
|
+
0.3.25 Part C (per-repo instance lock): `first=True` is also the ONE
|
|
496
|
+
moment this process tries to become the primary Webbee for this repo
|
|
497
|
+
(`instance_lock.acquire`) — a SECOND process opening the same repo finds
|
|
498
|
+
the lock already held and is told so (`lock.held`); when `slot_id` was
|
|
499
|
+
auto-minted (the caller didn't pass one — real production boot, never a
|
|
500
|
+
deterministic test) this instance then mints a short hex id for what
|
|
501
|
+
would otherwise be its own legacy tab-1 id, exactly like every LATER tab
|
|
502
|
+
already does, and an honest note lands in this slot's own transcript.
|
|
503
|
+
The `InstanceLock` itself rides on `slot.instance_lock` (a dynamic
|
|
504
|
+
attribute, same pattern as `agent.slot_id` below it) — `run_repl`'s own
|
|
505
|
+
teardown closes it at process exit, releasing the flock for a future
|
|
506
|
+
process on this repo."""
|
|
493
507
|
from uuid import uuid4
|
|
494
508
|
|
|
509
|
+
from webbee import instance_lock as _instance_lock
|
|
495
510
|
from webbee import tui
|
|
496
511
|
from webbee.mode_store import load_mode
|
|
497
512
|
from webbee.render import RichSink
|
|
513
|
+
from webbee.repo import compute_repo_key, find_repo_root
|
|
498
514
|
from webbee.sizing import get_size
|
|
499
515
|
|
|
500
|
-
|
|
516
|
+
auto_slot_id = slot_id is None
|
|
517
|
+
if auto_slot_id:
|
|
501
518
|
slot_id = "" if first else uuid4().hex[:6]
|
|
519
|
+
|
|
520
|
+
lock = None
|
|
521
|
+
lock_note = ""
|
|
522
|
+
if first:
|
|
523
|
+
def _repo_key() -> str: # runs `git remote get-url` -- keep off the event loop
|
|
524
|
+
return compute_repo_key(find_repo_root(workspace))
|
|
525
|
+
lock = _instance_lock.acquire(await asyncio.to_thread(_repo_key))
|
|
526
|
+
if lock.held:
|
|
527
|
+
lock_note = ("another Webbee is already running in this repo — "
|
|
528
|
+
"this window is a separate parallel session")
|
|
529
|
+
if auto_slot_id:
|
|
530
|
+
slot_id = uuid4().hex[:6]
|
|
531
|
+
|
|
502
532
|
effective_mode = await asyncio.to_thread(load_mode, workspace) or mode
|
|
503
533
|
width, _height = get_size(None) # pre-app: same fallback tui.run_session's own sizing uses
|
|
504
534
|
pane = tui.OutputPane(width=width)
|
|
@@ -511,6 +541,9 @@ async def _make_session_slot(cfg, token_provider, workspace, mode, *, resources:
|
|
|
511
541
|
intel_factory=intel_factory, shadow_factory=shadow_factory,
|
|
512
542
|
pane=pane, sink=sink, first=first, account=account,
|
|
513
543
|
slot_id=slot_id, label=label)
|
|
544
|
+
slot.instance_lock = lock
|
|
545
|
+
if lock_note:
|
|
546
|
+
sink.note(lock_note)
|
|
514
547
|
if wt_note:
|
|
515
548
|
sink.note(wt_note)
|
|
516
549
|
return (slot, replayed) if _with_replayed else slot
|
|
@@ -573,11 +606,19 @@ async def _home_input(text: str, *, slots: SlotManager, cfg, token_provider, mod
|
|
|
573
606
|
ui_hooks.get("switch", slots.switch)(idx)
|
|
574
607
|
if spawn_poller is not None:
|
|
575
608
|
spawn_poller(new_slot)
|
|
576
|
-
new_slot.sink.user_echo(text)
|
|
577
609
|
start_turn_in = ui_hooks.get("start_turn_in")
|
|
578
610
|
if start_turn_in is not None:
|
|
611
|
+
# 0.3.24 fix: the dock's start-turn seam threads through on_line ->
|
|
612
|
+
# `_handle`, which ALREADY echoes the typed line itself for every
|
|
613
|
+
# non-command line (the SAME canonical echo every other typed line
|
|
614
|
+
# in the dock gets) -- echoing here too doubled it up in the new
|
|
615
|
+
# tab's transcript (Valentin, live on 0.3.22/0.3.23).
|
|
579
616
|
start_turn_in(new_slot, text)
|
|
580
617
|
else:
|
|
618
|
+
# No dock: `run_turn` (repl's `_run_turn`) never echoes on its own
|
|
619
|
+
# (it only runs the agent turn), so this IS the one echo the typed
|
|
620
|
+
# line gets.
|
|
621
|
+
new_slot.sink.user_echo(text)
|
|
581
622
|
await run_turn(new_slot, text)
|
|
582
623
|
|
|
583
624
|
|
|
@@ -817,30 +858,13 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
817
858
|
_say(slot, f"Remote control unavailable: {type(e).__name__}")
|
|
818
859
|
return "continue"
|
|
819
860
|
if res.action == "new_tab":
|
|
820
|
-
#
|
|
821
|
-
#
|
|
822
|
-
#
|
|
823
|
-
#
|
|
824
|
-
#
|
|
825
|
-
#
|
|
826
|
-
|
|
827
|
-
# no matter which tab you typed it from. Always `first=False`
|
|
828
|
-
# (map §6 replay landmine): only the process's very first
|
|
829
|
-
# session slot ever gets the welcome banner + thread replay.
|
|
830
|
-
# A fresh tab starts in the process's BASELINE `mode`, never
|
|
831
|
-
# inheriting whatever the active tab is currently running in
|
|
832
|
-
# — an autopilot tab must never spawn another autopilot tab
|
|
833
|
-
# silently.
|
|
834
|
-
ws = os.path.abspath(res.arg) if res.arg else (slot.workspace or workspace)
|
|
835
|
-
new_slot = await _make_session_slot(
|
|
836
|
-
cfg, token_provider, ws, mode, resources=resources,
|
|
837
|
-
shared_client=shared_client, agent_factory=agent_factory,
|
|
838
|
-
intel_factory=intel_factory, shadow_factory=shadow_factory,
|
|
839
|
-
first=False)
|
|
840
|
-
idx = slots.add(new_slot)
|
|
841
|
-
ui_hooks.get("switch", slots.switch)(idx)
|
|
842
|
-
_spawn_slot_poller(new_slot)
|
|
843
|
-
new_slot.sink.note(f"tab {idx} opened — {new_slot.label}")
|
|
861
|
+
# `_open_new_tab` (shared with the tab bar's + chip, W4c Part
|
|
862
|
+
# B item 5) resolves the SAME "no path -> clone whatever's
|
|
863
|
+
# active" / "a given path is process-cwd-relative" contract
|
|
864
|
+
# this action always had. Always `first=False` (map §6
|
|
865
|
+
# replay landmine): only the process's very first session
|
|
866
|
+
# slot ever gets the welcome banner + thread replay.
|
|
867
|
+
await _open_new_tab(res.arg)
|
|
844
868
|
return "continue"
|
|
845
869
|
if res.action == "tab_switch":
|
|
846
870
|
try:
|
|
@@ -881,6 +905,21 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
881
905
|
for i, s in enumerate(slots.slots)]
|
|
882
906
|
_say(slot, "Open tabs:\n" + "\n".join(lines))
|
|
883
907
|
return "continue"
|
|
908
|
+
if res.action == "rename":
|
|
909
|
+
# W4c T3: a manual rename ALWAYS wins -- `label_pinned = True`
|
|
910
|
+
# locks the title against the one-shot auto-label in
|
|
911
|
+
# `_run_turn_on` (and against any future /rename overwriting
|
|
912
|
+
# THIS one accidentally). Gated by _HOME_GATED_ACTIONS (Home
|
|
913
|
+
# has no title of its own to rename), so `slot` here is
|
|
914
|
+
# always a real session slot.
|
|
915
|
+
name = sanitize_label(res.arg) if res.arg else ""
|
|
916
|
+
if not name:
|
|
917
|
+
_say(slot, "Usage: /rename <name>")
|
|
918
|
+
else:
|
|
919
|
+
slot.label = name
|
|
920
|
+
slot.label_pinned = True
|
|
921
|
+
_say(slot, f"tab renamed → {name}")
|
|
922
|
+
return "continue"
|
|
884
923
|
if res.action == "queue_clear":
|
|
885
924
|
# dispatch built the message (with the drop count) from the
|
|
886
925
|
# ctx snapshot; here we drop the live deque — the toolbar
|
|
@@ -919,7 +958,22 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
919
958
|
deliberately targets a DIFFERENT slot than whatever's on screen --
|
|
920
959
|
W4b T5's `_steer_submit_on` is the poller's own caller now, each
|
|
921
960
|
bound to its OWN slot). `_run_turn` below is the thin active()-
|
|
922
|
-
resolving wrapper every typed-line call site keeps using unchanged.
|
|
961
|
+
resolving wrapper every typed-line call site keeps using unchanged.
|
|
962
|
+
|
|
963
|
+
W4c T3 (self-naming tabs): every call here is a genuine task line
|
|
964
|
+
(never a slash command -- `_handle` handles those itself and returns
|
|
965
|
+
before reaching this function), so the FIRST one a not-yet-labeled
|
|
966
|
+
slot ever runs doubles as the browser-tab-title moment -- `label_
|
|
967
|
+
pinned` is the ONE flag guarding this (see its own field comment on
|
|
968
|
+
SessionSlot): still False means neither a manual `/rename` nor an
|
|
969
|
+
earlier auto-label has claimed this slot's title yet. `auto_label`
|
|
970
|
+
returning "" (blank/pure-noise text) leaves today's default label
|
|
971
|
+
untouched rather than blanking it."""
|
|
972
|
+
if not slot.label_pinned:
|
|
973
|
+
label = auto_label(line)
|
|
974
|
+
if label:
|
|
975
|
+
slot.label = label
|
|
976
|
+
slot.label_pinned = True
|
|
923
977
|
_sink = slot.sink
|
|
924
978
|
_sink.begin_turn()
|
|
925
979
|
kw = {"surface": surface} if surface else {}
|
|
@@ -1094,10 +1148,42 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1094
1148
|
slot, text, surface, steer_iid),
|
|
1095
1149
|
on_mode=lambda mode, surface: _on_mode(slot, mode, surface),
|
|
1096
1150
|
mode_getter=lambda: slot.mode,
|
|
1151
|
+
label_getter=lambda: slot.label,
|
|
1097
1152
|
attach_turn=lambda attach_info: _attach_turn_on(slot, attach_info),
|
|
1098
1153
|
client=shared_client, slot_id=slot.slot_id, initial_delay=offset))
|
|
1099
1154
|
slot.bg_tasks.append(task)
|
|
1100
1155
|
|
|
1156
|
+
async def _open_new_tab(path: str = "") -> SessionSlot:
|
|
1157
|
+
"""The shared "open a new tab" flow (Task 5's `/new` action; W4c
|
|
1158
|
+
Part B item 5 generalizes it for the tab bar's + chip): builds a
|
|
1159
|
+
session slot exactly like `/new` always has (`_make_session_slot`,
|
|
1160
|
+
`first=False`, the process's BASELINE `mode` — an autopilot tab must
|
|
1161
|
+
never spawn another autopilot tab silently), adds it, and switches
|
|
1162
|
+
through `ui_hooks.get("switch", slots.switch)` — the SAME
|
|
1163
|
+
`_switch_to` a click, Ctrl-T or a repl command already goes through,
|
|
1164
|
+
so the history/draft swap always runs, never bypassed.
|
|
1165
|
+
|
|
1166
|
+
`path=""` (the + chip's own call — tui's `on_new` always calls this
|
|
1167
|
+
with no args — and `/new` with no argument) clones the CURRENTLY
|
|
1168
|
+
ACTIVE slot's own workspace, falling back to the process cwd only if
|
|
1169
|
+
that's somehow empty too; a GIVEN path is resolved against the
|
|
1170
|
+
process's cwd (`os.path.abspath`, the SAME anchor `workspace` itself
|
|
1171
|
+
uses everywhere else in this file), not the active slot's own
|
|
1172
|
+
directory — so `/new ../sibling` means the same thing no matter
|
|
1173
|
+
which tab you typed it from."""
|
|
1174
|
+
active = slots.active()
|
|
1175
|
+
ws = os.path.abspath(path) if path else (active.workspace or workspace)
|
|
1176
|
+
new_slot = await _make_session_slot(
|
|
1177
|
+
cfg, token_provider, ws, mode, resources=resources,
|
|
1178
|
+
shared_client=shared_client, agent_factory=agent_factory,
|
|
1179
|
+
intel_factory=intel_factory, shadow_factory=shadow_factory,
|
|
1180
|
+
first=False)
|
|
1181
|
+
idx = slots.add(new_slot)
|
|
1182
|
+
ui_hooks.get("switch", slots.switch)(idx)
|
|
1183
|
+
_spawn_slot_poller(new_slot)
|
|
1184
|
+
new_slot.sink.note(f"tab {idx} opened — {new_slot.label}")
|
|
1185
|
+
return new_slot
|
|
1186
|
+
|
|
1101
1187
|
if use_dock:
|
|
1102
1188
|
ok = False
|
|
1103
1189
|
# Route stderr to a log file for the dock's ENTIRE lifetime (boot's
|
|
@@ -1161,9 +1247,17 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1161
1247
|
workspace=workspace, ui_hooks=ui_hooks, run_turn=_run_turn,
|
|
1162
1248
|
spawn_poller=_spawn_slot_poller),
|
|
1163
1249
|
on_switch=lambda idx: _schedule_home_refill(slots, idx, home_fill_kwargs),
|
|
1250
|
+
on_new=lambda: _open_new_tab(),
|
|
1164
1251
|
)
|
|
1165
1252
|
finally:
|
|
1166
1253
|
_cancel_background()
|
|
1254
|
+
# Part C: release the per-repo instance lock (if this
|
|
1255
|
+
# process actually acquired one -- None on a secondary
|
|
1256
|
+
# instance or a lock-unavailable filesystem) so a FUTURE
|
|
1257
|
+
# process opening this same repo can become primary.
|
|
1258
|
+
_lock = getattr(session_slot, "instance_lock", None)
|
|
1259
|
+
if _lock is not None:
|
|
1260
|
+
_lock.close()
|
|
1167
1261
|
if shared_client is not None:
|
|
1168
1262
|
try:
|
|
1169
1263
|
await shared_client.aclose()
|
|
@@ -7,9 +7,72 @@ WorkspaceResources caches the per-WORKSPACE pieces (intel, shadow, watcher,
|
|
|
7
7
|
git branch) shared by slots on the same repo root (map §6 boot split)."""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import re
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
12
|
from collections import deque
|
|
12
13
|
|
|
14
|
+
# Auto-label (W4c T3): a session tab renames itself from its first task's
|
|
15
|
+
# text, browser-tab-title style. ANSI CSI sequences (colors/cursor moves --
|
|
16
|
+
# "\x1b[31m...") are stripped as a WHOLE run FIRST (the run's own bytes,
|
|
17
|
+
# ESC included, are never whitespace so collapsing later wouldn't touch
|
|
18
|
+
# them); whitespace is collapsed NEXT -- \s already covers tab/newline/CR,
|
|
19
|
+
# so a tab or newline inside the pasted text becomes a single space rather
|
|
20
|
+
# than being silently deleted (which would glue two words together); any
|
|
21
|
+
# STILL-remaining bare control byte (a lone ESC not part of a CSI run, NUL,
|
|
22
|
+
# etc. -- never a real word separator) is dropped LAST, after whitespace is
|
|
23
|
+
# already settled, so it simply vanishes without leaving a gap.
|
|
24
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
|
|
25
|
+
_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
26
|
+
_WS_RE = re.compile(r"\s+")
|
|
27
|
+
AUTO_LABEL_MAX = 24
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sanitize_whitespace(text: str) -> str:
|
|
31
|
+
"""PURE. The ANSI/control/whitespace cleanup shared by `auto_label` and
|
|
32
|
+
`sanitize_label` below -- see the module comment above for the ordering
|
|
33
|
+
rationale (ANSI runs first, then whitespace collapse, then leftover
|
|
34
|
+
bare control bytes)."""
|
|
35
|
+
cleaned = _ANSI_RE.sub("", text or "")
|
|
36
|
+
cleaned = _WS_RE.sub(" ", cleaned)
|
|
37
|
+
return _CONTROL_RE.sub("", cleaned).strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def auto_label(text: str) -> str:
|
|
41
|
+
"""PURE. A compact browser-tab-style title from `text` (a session's
|
|
42
|
+
first task): sanitized (ANSI/control stripped -- see module comment
|
|
43
|
+
above), internal whitespace collapsed to single spaces, then cut to
|
|
44
|
+
`AUTO_LABEL_MAX` chars at the last whole word that still fits inside
|
|
45
|
+
that budget (falling back to a hard cut only when no space exists in
|
|
46
|
+
it at all -- same "no perfect boundary, still bounded" fallback
|
|
47
|
+
`tabs._fit` uses) with a trailing `…` -- appended ONLY when the
|
|
48
|
+
cleaned text was actually longer than the budget, never tacked onto
|
|
49
|
+
something that already fit as-is. "" (empty/all-control/all-
|
|
50
|
+
whitespace input) tells the caller to leave the slot's current label
|
|
51
|
+
alone."""
|
|
52
|
+
cleaned = _sanitize_whitespace(text)
|
|
53
|
+
if not cleaned:
|
|
54
|
+
return ""
|
|
55
|
+
if len(cleaned) <= AUTO_LABEL_MAX:
|
|
56
|
+
return cleaned
|
|
57
|
+
cut = cleaned[:AUTO_LABEL_MAX]
|
|
58
|
+
boundary = cut.rfind(" ")
|
|
59
|
+
if boundary > 0:
|
|
60
|
+
cut = cut[:boundary]
|
|
61
|
+
return cut.rstrip() + "…"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
RENAME_LABEL_MAX = 32
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sanitize_label(text: str, max_len: int = RENAME_LABEL_MAX) -> str:
|
|
68
|
+
"""PURE. `/rename`'s own sanitizer (repl.py): the SAME ANSI/control/
|
|
69
|
+
whitespace cleanup `auto_label` uses, but a plain hard cap at `max_len`
|
|
70
|
+
chars -- no word-boundary search, no trailing `…`. The user explicitly
|
|
71
|
+
typed this name; truncating it silently at a predictable length beats
|
|
72
|
+
surprising them with an ellipsis they never asked for. "" either way on
|
|
73
|
+
empty/all-noise input."""
|
|
74
|
+
return _sanitize_whitespace(text)[:max_len]
|
|
75
|
+
|
|
13
76
|
|
|
14
77
|
@dataclass
|
|
15
78
|
class SessionSlot:
|
|
@@ -24,9 +87,25 @@ class SessionSlot:
|
|
|
24
87
|
# every LATER session slot mints a short hex id
|
|
25
88
|
# (_make_session_slot), threaded into the agent's
|
|
26
89
|
# POST body + the steer poller's derived id.
|
|
90
|
+
label_pinned: bool = False # W4c T3: True once the label is LOCKED -- either
|
|
91
|
+
# a manual /rename, or the one-shot auto-label
|
|
92
|
+
# already fired (see repl._run_turn_on). Deliberately
|
|
93
|
+
# the ONE flag for both: re-deriving "is this still
|
|
94
|
+
# the default repo-basename label" from `workspace`
|
|
95
|
+
# would misfire on an auto-isolated worktree slot
|
|
96
|
+
# (its `workspace` is the worktree path, not the
|
|
97
|
+
# original repo whose basename became the label).
|
|
98
|
+
close_armed: bool = False # W4c Part D: a ✕ click on a BUSY tab arms this
|
|
99
|
+
# instead of closing outright (tui._close_tab_click);
|
|
100
|
+
# a second click while armed closes for real. Any
|
|
101
|
+
# switch or keypress disarms it (tui._disarm_all).
|
|
27
102
|
pending: deque = field(default_factory=deque)
|
|
28
103
|
turn: dict = field(default_factory=lambda: {"task": None})
|
|
29
104
|
pulled: dict = field(default_factory=lambda: {"text": "", "iid": ""})
|
|
105
|
+
draft: str = "" # 0.3.24: per-tab unsent input -- stashed on switch-away,
|
|
106
|
+
# restored on switch-back (browser-tab model: each tab
|
|
107
|
+
# keeps its own form state); cleared on genuine submit.
|
|
108
|
+
draft_cursor: int = 0 # cursor position within `draft`, restored alongside it
|
|
30
109
|
qp_ui: dict = field(default_factory=lambda: {"collapsed": False})
|
|
31
110
|
tp_ui: dict = field(default_factory=lambda: {"collapsed": False})
|
|
32
111
|
mode: str = "default"
|
|
@@ -123,6 +202,28 @@ def close_active(slots: SlotManager, cancel_slot=None) -> bool:
|
|
|
123
202
|
return close_at(slots, slots.active_idx, cancel_slot)
|
|
124
203
|
|
|
125
204
|
|
|
205
|
+
def is_turn_alive(slot: SessionSlot) -> bool:
|
|
206
|
+
"""PURE predicate (Part D — busy-tab close confirm): True iff `slot`'s
|
|
207
|
+
OWN turn task (the same `turn["task"]` `_cancel_slot`/`_gate_busy`
|
|
208
|
+
already read) is genuinely still running. Only the dock path ever
|
|
209
|
+
populates `turn["task"]` at all -- a slot whose turn dict stays
|
|
210
|
+
`{"task": None}` (Home, the fallback loop, a finished turn) is never
|
|
211
|
+
considered busy."""
|
|
212
|
+
task = slot.turn.get("task")
|
|
213
|
+
return task is not None and not task.done()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def disarm_all(slots: SlotManager) -> None:
|
|
217
|
+
"""Part D: clear every slot's one-shot `close_armed` flag -- tui wires
|
|
218
|
+
this into `_switch_to` (any tab switch) and the Application's
|
|
219
|
+
`after_key_press` event (any keypress) alike, so a busy tab's armed
|
|
220
|
+
"click ✕ again" state never lingers past the moment the user does
|
|
221
|
+
anything else. Unconditional and idempotent -- cheaper to reset every
|
|
222
|
+
slot than to track which one (if any) was armed."""
|
|
223
|
+
for s in slots.slots:
|
|
224
|
+
s.close_armed = False
|
|
225
|
+
|
|
226
|
+
|
|
126
227
|
class WorkspaceResources:
|
|
127
228
|
"""Per-repo-root shared pieces (map §6): intel+watcher, shadow, git
|
|
128
229
|
branch. Keyed by realpath of the repo root; get() returns the cached
|
|
@@ -89,6 +89,7 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
|
|
|
89
89
|
live_session_id=lambda: "",
|
|
90
90
|
on_mode=None,
|
|
91
91
|
mode_getter=None,
|
|
92
|
+
label_getter=None,
|
|
92
93
|
interval: float = _POLL_INTERVAL,
|
|
93
94
|
client=None,
|
|
94
95
|
idle_after_s: float = 300.0,
|
|
@@ -139,6 +140,16 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
|
|
|
139
140
|
at the fast cadence, no extra poke required.
|
|
140
141
|
None/"" omits the query param entirely (old
|
|
141
142
|
wiring, or no session polled yet).
|
|
143
|
+
* label_getter() -- sync, optional (W4c T3, label sync); returns
|
|
144
|
+
the CURRENT tab title of the session actually
|
|
145
|
+
being polled -- same "read fresh every tick,
|
|
146
|
+
never blindly slots.active()" discipline as
|
|
147
|
+
`mode_getter` above. Handed to
|
|
148
|
+
fetch_pending_steer as `label=`, which appends
|
|
149
|
+
`&label={label}` (urlencoded) so a self-named or
|
|
150
|
+
/rename'd tab reaches the gateway within one
|
|
151
|
+
poll tick. None/"" omits the query param
|
|
152
|
+
entirely, identical to `mode_getter` absent.
|
|
142
153
|
* slot_id -- W4b T5, optional; threaded verbatim into
|
|
143
154
|
`derive_session_id` so a LATER tab's poller
|
|
144
155
|
derives ITS OWN `-s{slot_id}` session id (never
|
|
@@ -215,6 +226,9 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
|
|
|
215
226
|
mode = mode_getter() if mode_getter is not None else ""
|
|
216
227
|
if mode:
|
|
217
228
|
fetch_kw["mode"] = mode
|
|
229
|
+
label = label_getter() if label_getter is not None else ""
|
|
230
|
+
if label:
|
|
231
|
+
fetch_kw["label"] = label
|
|
218
232
|
payload = await fetch_pending_steer(cfg, token_provider, sid, **fetch_kw) or {}
|
|
219
233
|
if payload.get("items"):
|
|
220
234
|
last_active = now() # a successful drain = activity
|