webbee 0.3.24__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.
Files changed (110) hide show
  1. {webbee-0.3.24 → webbee-0.3.25}/CHANGELOG.md +25 -0
  2. {webbee-0.3.24 → webbee-0.3.25}/PKG-INFO +1 -1
  3. {webbee-0.3.24 → webbee-0.3.25}/pyproject.toml +1 -1
  4. webbee-0.3.25/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/commands.py +5 -0
  6. webbee-0.3.25/src/webbee/instance_lock.py +83 -0
  7. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/repl.py +115 -29
  8. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/slots.py +97 -0
  9. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/steer.py +14 -0
  10. webbee-0.3.25/src/webbee/tabs.py +191 -0
  11. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/thread.py +15 -2
  12. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/tui.py +101 -12
  13. {webbee-0.3.24 → webbee-0.3.25}/tests/conftest.py +11 -0
  14. {webbee-0.3.24 → webbee-0.3.25}/tests/test_commands.py +16 -0
  15. webbee-0.3.25/tests/test_instance_lock.py +89 -0
  16. {webbee-0.3.24 → webbee-0.3.25}/tests/test_repl.py +183 -0
  17. {webbee-0.3.24 → webbee-0.3.25}/tests/test_slots.py +130 -1
  18. {webbee-0.3.24 → webbee-0.3.25}/tests/test_steer.py +109 -0
  19. {webbee-0.3.24 → webbee-0.3.25}/tests/test_tabs.py +207 -49
  20. {webbee-0.3.24 → webbee-0.3.25}/tests/test_thread.py +115 -0
  21. {webbee-0.3.24 → webbee-0.3.25}/tests/test_tui.py +351 -7
  22. webbee-0.3.24/src/webbee/__init__.py +0 -1
  23. webbee-0.3.24/src/webbee/tabs.py +0 -140
  24. {webbee-0.3.24 → webbee-0.3.25}/.github/workflows/publish.yml +0 -0
  25. {webbee-0.3.24 → webbee-0.3.25}/.gitignore +0 -0
  26. {webbee-0.3.24 → webbee-0.3.25}/LICENSE +0 -0
  27. {webbee-0.3.24 → webbee-0.3.25}/README.md +0 -0
  28. {webbee-0.3.24 → webbee-0.3.25}/install.sh +0 -0
  29. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/account.py +0 -0
  30. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/active_sessions.py +0 -0
  31. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/banner_art.py +0 -0
  32. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/boot.py +0 -0
  33. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/checkpoints.py +0 -0
  34. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/cli.py +0 -0
  35. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/clipboard.py +0 -0
  36. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/coding_context.py +0 -0
  37. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/config.py +0 -0
  38. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/consent.py +0 -0
  39. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/details.py +0 -0
  40. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/events.py +0 -0
  41. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/frames.py +0 -0
  42. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/home.py +0 -0
  43. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/http.py +0 -0
  44. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/intel/__init__.py +0 -0
  45. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/intel/chunker.py +0 -0
  46. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/intel/embed.py +0 -0
  47. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/intel/graph.py +0 -0
  48. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/intel/indexer.py +0 -0
  49. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/intel/models.py +0 -0
  50. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/intel/query.py +0 -0
  51. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/intel/service.py +0 -0
  52. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/intel/store.py +0 -0
  53. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/intel/vectors.py +0 -0
  54. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/intel/watch.py +0 -0
  55. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/mode_store.py +0 -0
  56. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/output_pane.py +0 -0
  57. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/queue_panel.py +0 -0
  58. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/reflow.py +0 -0
  59. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/remote.py +0 -0
  60. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/render.py +0 -0
  61. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/repo.py +0 -0
  62. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/selection.py +0 -0
  63. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/session.py +0 -0
  64. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/sessions.py +0 -0
  65. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/sizing.py +0 -0
  66. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/stream.py +0 -0
  67. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/todo_panel.py +0 -0
  68. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/tokens.py +0 -0
  69. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/tools.py +0 -0
  70. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/update.py +0 -0
  71. {webbee-0.3.24 → webbee-0.3.25}/src/webbee/worktrees.py +0 -0
  72. {webbee-0.3.24 → webbee-0.3.25}/tests/__init__.py +0 -0
  73. {webbee-0.3.24 → webbee-0.3.25}/tests/test_account.py +0 -0
  74. {webbee-0.3.24 → webbee-0.3.25}/tests/test_active_sessions.py +0 -0
  75. {webbee-0.3.24 → webbee-0.3.25}/tests/test_checkpoints.py +0 -0
  76. {webbee-0.3.24 → webbee-0.3.25}/tests/test_cli.py +0 -0
  77. {webbee-0.3.24 → webbee-0.3.25}/tests/test_clipboard.py +0 -0
  78. {webbee-0.3.24 → webbee-0.3.25}/tests/test_config.py +0 -0
  79. {webbee-0.3.24 → webbee-0.3.25}/tests/test_cpc_contract_stable.py +0 -0
  80. {webbee-0.3.24 → webbee-0.3.25}/tests/test_details.py +0 -0
  81. {webbee-0.3.24 → webbee-0.3.25}/tests/test_events.py +0 -0
  82. {webbee-0.3.24 → webbee-0.3.25}/tests/test_freeze_fix.py +0 -0
  83. {webbee-0.3.24 → webbee-0.3.25}/tests/test_home.py +0 -0
  84. {webbee-0.3.24 → webbee-0.3.25}/tests/test_intel_chunker.py +0 -0
  85. {webbee-0.3.24 → webbee-0.3.25}/tests/test_intel_embed.py +0 -0
  86. {webbee-0.3.24 → webbee-0.3.25}/tests/test_intel_graph.py +0 -0
  87. {webbee-0.3.24 → webbee-0.3.25}/tests/test_intel_indexer.py +0 -0
  88. {webbee-0.3.24 → webbee-0.3.25}/tests/test_intel_no_numpy.py +0 -0
  89. {webbee-0.3.24 → webbee-0.3.25}/tests/test_intel_query.py +0 -0
  90. {webbee-0.3.24 → webbee-0.3.25}/tests/test_intel_service.py +0 -0
  91. {webbee-0.3.24 → webbee-0.3.25}/tests/test_intel_store.py +0 -0
  92. {webbee-0.3.24 → webbee-0.3.25}/tests/test_intel_vectors.py +0 -0
  93. {webbee-0.3.24 → webbee-0.3.25}/tests/test_intel_watch.py +0 -0
  94. {webbee-0.3.24 → webbee-0.3.25}/tests/test_marathon.py +0 -0
  95. {webbee-0.3.24 → webbee-0.3.25}/tests/test_mode_store.py +0 -0
  96. {webbee-0.3.24 → webbee-0.3.25}/tests/test_packaging.py +0 -0
  97. {webbee-0.3.24 → webbee-0.3.25}/tests/test_reflow.py +0 -0
  98. {webbee-0.3.24 → webbee-0.3.25}/tests/test_render.py +0 -0
  99. {webbee-0.3.24 → webbee-0.3.25}/tests/test_repo.py +0 -0
  100. {webbee-0.3.24 → webbee-0.3.25}/tests/test_session.py +0 -0
  101. {webbee-0.3.24 → webbee-0.3.25}/tests/test_sessions.py +0 -0
  102. {webbee-0.3.24 → webbee-0.3.25}/tests/test_sizing.py +0 -0
  103. {webbee-0.3.24 → webbee-0.3.25}/tests/test_stream.py +0 -0
  104. {webbee-0.3.24 → webbee-0.3.25}/tests/test_todo_panel.py +0 -0
  105. {webbee-0.3.24 → webbee-0.3.25}/tests/test_tokens.py +0 -0
  106. {webbee-0.3.24 → webbee-0.3.25}/tests/test_tools.py +0 -0
  107. {webbee-0.3.24 → webbee-0.3.25}/tests/test_tui_hardening.py +0 -0
  108. {webbee-0.3.24 → webbee-0.3.25}/tests/test_update.py +0 -0
  109. {webbee-0.3.24 → webbee-0.3.25}/tests/test_version.py +0 -0
  110. {webbee-0.3.24 → webbee-0.3.25}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,30 @@
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
+
3
28
  ## 0.3.24
4
29
 
5
30
  The tab bar got a real design: the active tab is a solid highlighted chip,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.24
3
+ Version: 0.3.25
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.24"
3
+ version = "0.3.25"
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.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, close_active
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
- if slot_id is None:
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
@@ -825,30 +858,13 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
825
858
  _say(slot, f"Remote control unavailable: {type(e).__name__}")
826
859
  return "continue"
827
860
  if res.action == "new_tab":
828
- # Task 5: no path -> clone the ACTIVE slot's own workspace
829
- # into a fresh tab (falling back to the process cwd only if
830
- # that's somehow empty too defensive, shouldn't happen in
831
- # practice); a GIVEN path is resolved against the process's
832
- # cwd (`os.path.abspath`, the SAME anchor `workspace` itself
833
- # uses everywhere else in this file), not the active slot's
834
- # own directory — so `/new ../sibling` means the same thing
835
- # no matter which tab you typed it from. Always `first=False`
836
- # (map §6 replay landmine): only the process's very first
837
- # session slot ever gets the welcome banner + thread replay.
838
- # A fresh tab starts in the process's BASELINE `mode`, never
839
- # inheriting whatever the active tab is currently running in
840
- # — an autopilot tab must never spawn another autopilot tab
841
- # silently.
842
- ws = os.path.abspath(res.arg) if res.arg else (slot.workspace or workspace)
843
- new_slot = await _make_session_slot(
844
- cfg, token_provider, ws, mode, resources=resources,
845
- shared_client=shared_client, agent_factory=agent_factory,
846
- intel_factory=intel_factory, shadow_factory=shadow_factory,
847
- first=False)
848
- idx = slots.add(new_slot)
849
- ui_hooks.get("switch", slots.switch)(idx)
850
- _spawn_slot_poller(new_slot)
851
- 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)
852
868
  return "continue"
853
869
  if res.action == "tab_switch":
854
870
  try:
@@ -889,6 +905,21 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
889
905
  for i, s in enumerate(slots.slots)]
890
906
  _say(slot, "Open tabs:\n" + "\n".join(lines))
891
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"
892
923
  if res.action == "queue_clear":
893
924
  # dispatch built the message (with the drop count) from the
894
925
  # ctx snapshot; here we drop the live deque — the toolbar
@@ -927,7 +958,22 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
927
958
  deliberately targets a DIFFERENT slot than whatever's on screen --
928
959
  W4b T5's `_steer_submit_on` is the poller's own caller now, each
929
960
  bound to its OWN slot). `_run_turn` below is the thin active()-
930
- 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
931
977
  _sink = slot.sink
932
978
  _sink.begin_turn()
933
979
  kw = {"surface": surface} if surface else {}
@@ -1102,10 +1148,42 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1102
1148
  slot, text, surface, steer_iid),
1103
1149
  on_mode=lambda mode, surface: _on_mode(slot, mode, surface),
1104
1150
  mode_getter=lambda: slot.mode,
1151
+ label_getter=lambda: slot.label,
1105
1152
  attach_turn=lambda attach_info: _attach_turn_on(slot, attach_info),
1106
1153
  client=shared_client, slot_id=slot.slot_id, initial_delay=offset))
1107
1154
  slot.bg_tasks.append(task)
1108
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
+
1109
1187
  if use_dock:
1110
1188
  ok = False
1111
1189
  # Route stderr to a log file for the dock's ENTIRE lifetime (boot's
@@ -1169,9 +1247,17 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1169
1247
  workspace=workspace, ui_hooks=ui_hooks, run_turn=_run_turn,
1170
1248
  spawn_poller=_spawn_slot_poller),
1171
1249
  on_switch=lambda idx: _schedule_home_refill(slots, idx, home_fill_kwargs),
1250
+ on_new=lambda: _open_new_tab(),
1172
1251
  )
1173
1252
  finally:
1174
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()
1175
1261
  if shared_client is not None:
1176
1262
  try:
1177
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,6 +87,18 @@ 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": ""})
@@ -127,6 +202,28 @@ def close_active(slots: SlotManager, cancel_slot=None) -> bool:
127
202
  return close_at(slots, slots.active_idx, cancel_slot)
128
203
 
129
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
+
130
227
  class WorkspaceResources:
131
228
  """Per-repo-root shared pieces (map §6): intel+watcher, shadow, git
132
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