webbee 0.3.16__tar.gz → 0.3.17__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 (92) hide show
  1. {webbee-0.3.16 → webbee-0.3.17}/CHANGELOG.md +34 -0
  2. {webbee-0.3.16 → webbee-0.3.17}/PKG-INFO +1 -1
  3. {webbee-0.3.16 → webbee-0.3.17}/pyproject.toml +1 -1
  4. webbee-0.3.17/src/webbee/__init__.py +1 -0
  5. webbee-0.3.17/src/webbee/http.py +11 -0
  6. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/store.py +9 -2
  7. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/vectors.py +9 -0
  8. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/watch.py +9 -1
  9. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/output_pane.py +53 -19
  10. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/queue_panel.py +49 -14
  11. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/render.py +56 -6
  12. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/repl.py +84 -17
  13. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/session.py +148 -25
  14. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/steer.py +33 -4
  15. webbee-0.3.17/src/webbee/stream.py +115 -0
  16. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/thread.py +36 -22
  17. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/todo_panel.py +20 -7
  18. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/tokens.py +14 -0
  19. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/tools.py +10 -1
  20. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/tui.py +124 -27
  21. {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_store.py +21 -0
  22. webbee-0.3.17/tests/test_intel_vectors.py +51 -0
  23. {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_watch.py +12 -0
  24. {webbee-0.3.16 → webbee-0.3.17}/tests/test_marathon.py +5 -2
  25. {webbee-0.3.16 → webbee-0.3.17}/tests/test_render.py +116 -0
  26. {webbee-0.3.16 → webbee-0.3.17}/tests/test_repl.py +133 -1
  27. {webbee-0.3.16 → webbee-0.3.17}/tests/test_session.py +437 -15
  28. {webbee-0.3.16 → webbee-0.3.17}/tests/test_steer.py +48 -0
  29. webbee-0.3.17/tests/test_stream.py +389 -0
  30. {webbee-0.3.16 → webbee-0.3.17}/tests/test_thread.py +69 -0
  31. {webbee-0.3.16 → webbee-0.3.17}/tests/test_todo_panel.py +24 -0
  32. {webbee-0.3.16 → webbee-0.3.17}/tests/test_tokens.py +30 -0
  33. {webbee-0.3.16 → webbee-0.3.17}/tests/test_tools.py +27 -0
  34. {webbee-0.3.16 → webbee-0.3.17}/tests/test_tui.py +369 -7
  35. webbee-0.3.16/src/webbee/__init__.py +0 -1
  36. webbee-0.3.16/src/webbee/stream.py +0 -58
  37. webbee-0.3.16/tests/test_intel_vectors.py +0 -26
  38. webbee-0.3.16/tests/test_stream.py +0 -152
  39. {webbee-0.3.16 → webbee-0.3.17}/.github/workflows/publish.yml +0 -0
  40. {webbee-0.3.16 → webbee-0.3.17}/.gitignore +0 -0
  41. {webbee-0.3.16 → webbee-0.3.17}/LICENSE +0 -0
  42. {webbee-0.3.16 → webbee-0.3.17}/README.md +0 -0
  43. {webbee-0.3.16 → webbee-0.3.17}/install.sh +0 -0
  44. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/account.py +0 -0
  45. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/banner_art.py +0 -0
  46. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/boot.py +0 -0
  47. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/checkpoints.py +0 -0
  48. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/cli.py +0 -0
  49. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/clipboard.py +0 -0
  50. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/coding_context.py +0 -0
  51. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/commands.py +0 -0
  52. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/config.py +0 -0
  53. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/consent.py +0 -0
  54. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/details.py +0 -0
  55. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/events.py +0 -0
  56. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/frames.py +0 -0
  57. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/__init__.py +0 -0
  58. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/chunker.py +0 -0
  59. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/embed.py +0 -0
  60. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/graph.py +0 -0
  61. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/indexer.py +0 -0
  62. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/models.py +0 -0
  63. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/query.py +0 -0
  64. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/service.py +0 -0
  65. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/remote.py +0 -0
  66. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/repo.py +0 -0
  67. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/sessions.py +0 -0
  68. {webbee-0.3.16 → webbee-0.3.17}/src/webbee/update.py +0 -0
  69. {webbee-0.3.16 → webbee-0.3.17}/tests/__init__.py +0 -0
  70. {webbee-0.3.16 → webbee-0.3.17}/tests/test_account.py +0 -0
  71. {webbee-0.3.16 → webbee-0.3.17}/tests/test_checkpoints.py +0 -0
  72. {webbee-0.3.16 → webbee-0.3.17}/tests/test_cli.py +0 -0
  73. {webbee-0.3.16 → webbee-0.3.17}/tests/test_clipboard.py +0 -0
  74. {webbee-0.3.16 → webbee-0.3.17}/tests/test_commands.py +0 -0
  75. {webbee-0.3.16 → webbee-0.3.17}/tests/test_config.py +0 -0
  76. {webbee-0.3.16 → webbee-0.3.17}/tests/test_cpc_contract_stable.py +0 -0
  77. {webbee-0.3.16 → webbee-0.3.17}/tests/test_details.py +0 -0
  78. {webbee-0.3.16 → webbee-0.3.17}/tests/test_events.py +0 -0
  79. {webbee-0.3.16 → webbee-0.3.17}/tests/test_freeze_fix.py +0 -0
  80. {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_chunker.py +0 -0
  81. {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_embed.py +0 -0
  82. {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_graph.py +0 -0
  83. {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_indexer.py +0 -0
  84. {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_no_numpy.py +0 -0
  85. {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_query.py +0 -0
  86. {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_service.py +0 -0
  87. {webbee-0.3.16 → webbee-0.3.17}/tests/test_packaging.py +0 -0
  88. {webbee-0.3.16 → webbee-0.3.17}/tests/test_repo.py +0 -0
  89. {webbee-0.3.16 → webbee-0.3.17}/tests/test_sessions.py +0 -0
  90. {webbee-0.3.16 → webbee-0.3.17}/tests/test_tui_hardening.py +0 -0
  91. {webbee-0.3.16 → webbee-0.3.17}/tests/test_update.py +0 -0
  92. {webbee-0.3.16 → webbee-0.3.17}/tests/test_version.py +0 -0
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.17
4
+
5
+ Bulletproof core (W1): a marathon can no longer be killed by transport, the
6
+ queue survives every error class, and the client stops leaking memory.
7
+
8
+ - A gateway blip mid-run (502, deploy, network drop) no longer kills the turn:
9
+ the stream patiently reconnects and resumes exactly where it left off, the
10
+ toolbar shows an honest `⟳ reconnecting` state, and only a real sign-out
11
+ ends the run — with a clear "run /login" message instead of a raw error.
12
+ - A stream 401 gets ONE forced token refresh before it counts as a sign-out,
13
+ and a refresh that fails because the gateway was mid-deploy no longer burns
14
+ that chance.
15
+ - Every message now carries a dedup key end-to-end, so a retried send after an
16
+ ambiguous network failure can never execute the same instruction twice.
17
+ - The turn-start and result posts retry transient failures too — outage
18
+ recovery drops from minutes to seconds.
19
+ - A turn that ends in an error HOLDS the queued messages (with an honest note)
20
+ instead of burning them one failing turn at a time; a parked marathon keeps
21
+ its queued rows visible, tagged ⏸.
22
+ - A stuck busy flag can no longer starve remote-message pickup; a message the
23
+ kernel deduplicated ends its wait honestly instead of spinning forever.
24
+ - Pull a queued message to edit and resubmit it unchanged — it keeps its dedup
25
+ identity.
26
+ - Click the queue or todo panel header to collapse it to one row (▸/▾).
27
+ - Performance: one keep-alive connection replaces a TLS handshake every 4s;
28
+ the idle poll relaxes to 30s after 5 quiet minutes; hours-long marathons no
29
+ longer grow memory without bound; transcript rendering is O(new output) per
30
+ print; embedding vectors load memory-mapped.
31
+ - Windows groundwork: `.git` filters now match Windows paths (no reindex
32
+ storms).
33
+
34
+ Companion release: imperal-mcp 0.5.2 — only a real 401 means "signed out";
35
+ gateway 5xx/network errors during a token refresh are retryable.
36
+
3
37
  ## 0.3.16
4
38
 
5
39
  - A message typed while Webbee is working now shows in the queue panel
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.16
3
+ Version: 0.3.17
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.16"
3
+ version = "0.3.17"
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.17"
@@ -0,0 +1,11 @@
1
+ """ONE keep-alive httpx.AsyncClient for the client's small gateway calls.
2
+ Before W1 the idle-steer poller opened a NEW AsyncClient — a fresh TCP+TLS
3
+ handshake — every 4s tick, forever (thread.py per-call clients). The repl owns
4
+ one client for the loop's lifetime and passes it down; every callee keeps a
5
+ per-call fallback so tests and old call sites work unchanged."""
6
+ from __future__ import annotations
7
+
8
+
9
+ def make_client(cfg):
10
+ import httpx
11
+ return httpx.AsyncClient(base_url=cfg.api_url, timeout=10)
@@ -99,10 +99,17 @@ def load_vectors(cache_dir, repo_key, git_ref, model_id):
99
99
  if (meta.get("schema_version") != SCHEMA_VERSION or meta.get("git_ref") != git_ref
100
100
  or meta.get("model_id") != model_id):
101
101
  return None
102
- mat = np.load(os.path.join(d, "embeddings.npy"))
102
+ # mmap_mode="r": the on-disk cache can be large across a big repo's
103
+ # chunk set -- read it lazily off disk instead of paging the whole
104
+ # matrix into RAM up front. save_vectors always writes float32, so
105
+ # the astype copy below only fires on an actual dtype mismatch
106
+ # (e.g. an older/foreign cache file), not on every load.
107
+ mat = np.load(os.path.join(d, "embeddings.npy"), mmap_mode="r")
103
108
  ids = meta.get("ids") or []
104
109
  if len(ids) != mat.shape[0]:
105
110
  return None
106
- return ids, mat.astype(np.float32)
111
+ if mat.dtype != np.float32:
112
+ mat = mat.astype(np.float32)
113
+ return ids, mat
107
114
  except Exception:
108
115
  return None
@@ -20,6 +20,15 @@ class VectorStore:
20
20
  rows = []
21
21
  for i, _id in enumerate(ids):
22
22
  if _id in self._pos:
23
+ if not self._mat.flags.writeable:
24
+ # Copy-on-write: _mat may be a read-only mmap loaded
25
+ # straight off the vector cache (store.load_vectors'
26
+ # mmap_mode="r" perf win) via from_arrays. A read-only
27
+ # boot+search session never reaches this branch; only an
28
+ # incremental re-embed of an existing chunk id (same id,
29
+ # changed content) needs to mutate a row in place, so pay
30
+ # for exactly one full copy here, lazily.
31
+ self._mat = np.array(self._mat)
23
32
  self._mat[self._pos[_id]] = vecs[i]
24
33
  else:
25
34
  self._pos[_id] = len(self._ids)
@@ -6,6 +6,14 @@ import asyncio
6
6
  import os
7
7
 
8
8
 
9
+ def _ignored(path: str) -> bool:
10
+ """PURE. True for VCS/vendor churn. Normalized to forward slashes so the
11
+ filter works on Windows too (watchfiles yields backslash paths there --
12
+ unfiltered .git churn re-indexed the repo on every checkpoint)."""
13
+ p = path.replace(os.sep, "/").replace("\\", "/")
14
+ return "/.git/" in p or "/node_modules/" in p
15
+
16
+
9
17
  async def watch_workspace(root: str, on_change) -> None:
10
18
  """Call on_change(set_of_relpaths) as files change. Fail-soft: if
11
19
  watchfiles is unavailable, return immediately (no watcher)."""
@@ -16,7 +24,7 @@ async def watch_workspace(root: str, on_change) -> None:
16
24
  async for changes in awatch(root):
17
25
  rels = set()
18
26
  for _chg, path in changes:
19
- if "/.git/" in path or "/node_modules/" in path:
27
+ if _ignored(path):
20
28
  continue
21
29
  try:
22
30
  rels.add(os.path.relpath(path, root))
@@ -24,12 +24,12 @@ class OutputPane:
24
24
  self.console = Console(file=self._io, force_terminal=True,
25
25
  color_system="truecolor", width=width, highlight=False)
26
26
  self._ANSI = ANSI
27
- self._lines_cache = (None, [""]) # (write-pos, split-lines) — memoize the split
27
+ self._lines_cache = (0, [""]) # (write-pos, split-lines) — memoize the split
28
28
  self._offset = 0 # index of the top visible line
29
29
  self._view_h = 20 # viewport height (updated from render_info)
30
30
  self._follow = True # stick to the tail unless the user scrolled up
31
31
  self._sel = None # (abs_start, abs_end) during a drag → live highlight
32
- self._plain_cache = (None, [""]) # (write-pos, ANSI-stripped lines) for select/highlight
32
+ self._plain_cache = (0, [""]) # (write-pos, ANSI-stripped lines) for select/highlight
33
33
  self.copy_flash = "" # transient toolbar note after a copy
34
34
  self._flash_until = 0.0
35
35
  pane = self
@@ -80,21 +80,44 @@ class OutputPane:
80
80
  def _all_lines(self):
81
81
  # Key the cache on the stream WRITE POSITION (O(1)), not a full-buffer
82
82
  # getvalue()+string compare (O(session)) — that ran on EVERY redraw
83
- # (keystroke / ticker / scroll) and made big sessions lag. getvalue()
84
- # + re-split happen ONLY when new output actually arrived.
83
+ # (keystroke / ticker / scroll) and made big sessions lag. Beyond that,
84
+ # only the DELTA since the cached position is read and split — the
85
+ # cached list is extended IN PLACE, so a print costs O(new output),
86
+ # never a full-buffer re-split (that made long busy streams
87
+ # quadratic). Boundary safety: the cache only ever advances at print
88
+ # boundaries (Console.print completes before notify() runs), so an
89
+ # SGR escape can never split across a delta read.
85
90
  pos = self._io.tell()
86
- if self._lines_cache[0] != pos:
87
- s = self._io.getvalue()
88
- self._lines_cache = (pos, s.split("\n"))
89
- return self._lines_cache[1]
91
+ cpos, lines = self._lines_cache
92
+ if cpos == pos:
93
+ return lines
94
+ if isinstance(cpos, int) and 0 <= cpos < pos:
95
+ self._io.seek(cpos)
96
+ delta = self._io.read() # leaves position at EOF (== pos)
97
+ parts = delta.split("\n")
98
+ lines[-1] += parts[0]
99
+ lines.extend(parts[1:])
100
+ else:
101
+ lines = self._io.getvalue().split("\n")
102
+ self._lines_cache = (pos, lines)
103
+ return lines
90
104
 
91
105
  def _plain_lines(self):
92
106
  import re
93
107
  pos = self._io.tell()
94
- if self._plain_cache[0] != pos:
95
- s = self._io.getvalue()
96
- self._plain_cache = (pos, re.sub(r"\x1b\[[0-9;]*m", "", s).split("\n"))
97
- return self._plain_cache[1]
108
+ cpos, lines = self._plain_cache
109
+ if cpos == pos:
110
+ return lines
111
+ if isinstance(cpos, int) and 0 <= cpos < pos:
112
+ self._io.seek(cpos)
113
+ delta = re.sub(r"\x1b\[[0-9;]*m", "", self._io.read())
114
+ parts = delta.split("\n")
115
+ lines[-1] += parts[0]
116
+ lines.extend(parts[1:])
117
+ else:
118
+ lines = re.sub(r"\x1b\[[0-9;]*m", "", self._io.getvalue()).split("\n")
119
+ self._plain_cache = (pos, lines)
120
+ return lines
98
121
 
99
122
  def _norm_sel(self):
100
123
  (a, b) = self._sel
@@ -151,14 +174,25 @@ class OutputPane:
151
174
  except Exception:
152
175
  pass
153
176
 
154
- def _trim(self, max_lines: int = 20000) -> None:
177
+ def _trim(self, max_lines: int = 20000, keep: int = 15000) -> None:
178
+ # Hysteresis: only trim once past max_lines, and cut down to `keep`
179
+ # (not max_lines) — trimming is amortized over 5000 lines of headroom
180
+ # instead of firing on every single print once the ceiling is hit.
155
181
  import io
156
- s = self._io.getvalue()
157
- if s.count("\n") > max_lines:
158
- s = "\n".join(s.split("\n")[-max_lines:])
159
- self._io = io.StringIO()
160
- self._io.write(s)
161
- self.console.file = self._io
182
+ lines = self._all_lines()
183
+ if len(lines) <= max_lines:
184
+ return
185
+ dropped = len(lines) - keep
186
+ s = "\n".join(lines[-keep:])
187
+ self._io = io.StringIO()
188
+ self._io.write(s)
189
+ self.console.file = self._io
190
+ self._lines_cache = (0, [""])
191
+ self._plain_cache = (0, [""])
192
+ # A scrolled-up reader keeps looking at the SAME content lines — the
193
+ # dropped count is subtracted from `_offset` so the viewport doesn't
194
+ # silently jump when the buffer is rewritten underneath it.
195
+ self._offset = max(0, self._offset - dropped)
162
196
 
163
197
  def dump(self) -> str:
164
198
  """The full session transcript (ANSI). Printed to real stdout on exit so
@@ -24,17 +24,21 @@ def one_line(text: str, width: int) -> str:
24
24
  return t
25
25
 
26
26
 
27
- def queue_height(pending, remote=None) -> int:
27
+ def queue_height(pending, remote=None, collapsed=False) -> int:
28
28
  """PURE. Rows the panel needs: 1 header + one per SHOWN item + one
29
29
  `… +K more` row when a queue is deeper than QP_MAX_ITEMS (each of the
30
30
  two sections — remote rows and local rows — caps independently). 0 when
31
31
  both are empty (the ConditionalContainer hides the panel then anyway).
32
- The cap keeps the output pane dominant on small terminals; the toolbar's
33
- `⋯N queued` segment stays the truth-teller for the full depth."""
32
+ `collapsed=True` (Task 11 click-to-collapse) always costs exactly 1 row
33
+ when there's data screen space back on demand. The cap keeps the
34
+ output pane dominant on small terminals; the toolbar's `⋯N queued`
35
+ segment stays the truth-teller for the full depth."""
34
36
  n = len(pending)
35
37
  r = len(remote or ())
36
38
  if not n and not r:
37
39
  return 0
40
+ if collapsed:
41
+ return 1
38
42
  rows = 1
39
43
  if r:
40
44
  rows += min(r, QP_MAX_ITEMS) + (1 if r > QP_MAX_ITEMS else 0)
@@ -43,21 +47,23 @@ def queue_height(pending, remote=None) -> int:
43
47
  return rows
44
48
 
45
49
 
46
- def pull_item(pending, buf, index: int) -> bool:
50
+ def pull_item(pending, buf, index: int):
47
51
  """The ONE pull-to-edit implementation (serves BOTH the ↑ key — newest,
48
52
  index len(pending)-1 — and a panel-row click — that row's index): move
49
53
  pending[index] OUT of the queue and into the input buffer for editing,
50
54
  cursor at the end. Guards, identical on both paths: a buffer with ANY
51
55
  text is never clobbered, and a stale index (the queue drained between
52
- render and click) is ignored. Returns True when a pull happened (the
53
- caller invalidates)."""
56
+ render and click) is ignored. Returns the removed item (truthy) or None
57
+ callers truthy-check, so a QueuedLine's carried steer_iid rides back
58
+ out with it (tui._rewrap_pulled reuses it when the line is resubmitted
59
+ unchanged)."""
54
60
  if buf.text or not (0 <= index < len(pending)):
55
- return False
61
+ return None
56
62
  item = pending[index]
57
63
  del pending[index]
58
- buf.text = item
59
- buf.cursor_position = len(item)
60
- return True
64
+ buf.text = str(item)
65
+ buf.cursor_position = len(str(item))
66
+ return item
61
67
 
62
68
 
63
69
  def _item_handler(pull, index: int):
@@ -74,7 +80,21 @@ def _item_handler(pull, index: int):
74
80
  return _h
75
81
 
76
82
 
77
- def queue_fragments(pending, pull=None, width: int = 0, remote=None):
83
+ def _toggle_handler(toggle):
84
+ """Header mouse handler (Task 11 click-to-collapse): MOUSE_UP toggles
85
+ collapse; everything else falls through (NotImplemented) so wheel scroll
86
+ keeps working — the exact event discipline of _item_handler."""
87
+ def _h(mouse_event):
88
+ from prompt_toolkit.mouse_events import MouseEventType
89
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
90
+ toggle()
91
+ return None
92
+ return NotImplemented
93
+ return _h
94
+
95
+
96
+ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
97
+ collapsed=False, toggle=None):
78
98
  """PURE builder: the panel as prompt_toolkit formatted text, re-invoked
79
99
  every redraw (same live mechanics as the toolbar) so every queue
80
100
  add/edit/drain shows at once. Layout, top→bottom = drain order (FIFO —
@@ -97,13 +117,24 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None):
97
117
  The header counts both; the `↑ edit last` hint shows only when there is
98
118
  a local (pullable) item. When `pull` is given each LOCAL item row is a
99
119
  3-tuple fragment carrying a mouse handler that pulls exactly that item
100
- (see _item_handler). Both queues empty → [] (the panel is hidden)."""
120
+ (see _item_handler). Both queues empty → [] (the panel is hidden).
121
+
122
+ `collapsed` (Task 11 click-to-collapse) folds the whole panel down to
123
+ ONE header row ending `▸` (screen space back on demand); `▾` when
124
+ expanded. Both only render when `toggle` is given — the header then
125
+ carries a 3-tuple MOUSE_UP handler (see _toggle_handler) that flips it."""
101
126
  items = list(pending)
102
127
  rem = [r for r in (remote or ()) if isinstance(r, dict)]
103
128
  n = len(items)
104
129
  if not n and not rem:
105
130
  return []
106
- frags = [("class:qp.header", f" queued ({n + len(rem)})")]
131
+ marker = "" if toggle is None else (" ▸" if collapsed else " ▾")
132
+ header = ("class:qp.header", f" ⋯ queued ({n + len(rem)}){marker}")
133
+ if toggle is not None:
134
+ header = header + (_toggle_handler(toggle),)
135
+ frags = [header]
136
+ if collapsed:
137
+ return frags
107
138
  if n:
108
139
  frags.append(("class:qp.item", " · ↑ edit last · click to edit"))
109
140
  rstart = max(0, len(rem) - QP_MAX_ITEMS)
@@ -111,7 +142,11 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None):
111
142
  frags.append(("class:qp.remote", f"\n … +{rstart} more"))
112
143
  for r in rem[rstart:]:
113
144
  origin = str(r.get("origin") or "") or "remote"
114
- row = "\n " + one_line(f"[{origin}] {r.get('text') or ''}",
145
+ # A row surviving a marathon PARK (W1 front-3b) is still queued
146
+ # server-side, not phantom -- the ⏸ prefix tells the user it's
147
+ # waiting on a wake, not about to run right now.
148
+ mark = "⏸ " if r.get("parked") else ""
149
+ row = "\n " + one_line(f"{mark}[{origin}] {r.get('text') or ''}",
115
150
  width - 4 if width > 0 else 0)
116
151
  frags.append(("class:qp.remote", row))
117
152
  start = max(0, n - QP_MAX_ITEMS)
@@ -154,6 +154,10 @@ class RichSink:
154
154
  self.current_todos: list = []
155
155
  self._todo_counts = (0, 0) # kernel-reported (done, total)
156
156
  self._todos_dirty = False # a turn touched the list -> end_turn records it
157
+ self._reconnecting = 0 # stream transport down (W1 front-5): armed attempt#, 0 = none
158
+ self._reconnect_since = None # clock() at the moment the outage started
159
+ self._turn_failed = False # ERROR (not a user stop) ended the last turn (W1 front-3b)
160
+ self._parked = False # marathon PARKED this turn (W1 front-3b) -> end_turn keeps remote_pending
157
161
 
158
162
  def _nudge(self) -> None:
159
163
  """After output/state changes: let the pane follow the tail + redraw."""
@@ -217,17 +221,44 @@ class RichSink:
217
221
  self.tokens = 0 # per-turn live counters (usage frames are per-turn cumulative)
218
222
  self.credits = 0
219
223
  self._busy = True
224
+ self._reconnecting = 0
225
+ self._reconnect_since = None
226
+ self._turn_failed = False
227
+ self._parked = False
220
228
  self.console.print() # breathing room between the user's message and the response
221
229
  self._nudge()
222
230
 
231
+ def mark_turn_failed(self) -> None:
232
+ """The turn ended in an ERROR (not a user stop): the dock's drain rule
233
+ holds the type-ahead queue instead of burning one queued line into
234
+ each failing turn (W1 front-3b). One-turn flag; begin_turn clears."""
235
+ self._turn_failed = True
236
+
237
+ def marathon_parked(self, reason: str) -> None:
238
+ """The run PARKED (runaway/consent/credits — kernel keeps its task
239
+ queue alive waiting for a wake). end_turn must NOT clear the remote
240
+ rows: they are still queued server-side; the panel would lie
241
+ (W1 front-3b, the post-runaway 'empty panel over a non-empty kernel
242
+ queue'). Rows re-tag ⏸ parked; the next run's task_dequeued frames
243
+ remove them by iid as the kernel drains."""
244
+ self._parked = True
245
+
223
246
  def end_turn(self, final_text: str) -> None:
224
247
  self._busy = False
225
248
  # The kernel session's own queue only exists while a run is live —
226
- # once this turn returns (complete / stopped / parked) the terminal
227
- # no longer streams its dequeue frames, so any leftover remote rows
228
- # would linger as phantoms. Clear them (in place — the panel holds
229
- # this list object).
230
- self.remote_pending.clear()
249
+ # once this turn returns COMPLETE or user-stopped, the terminal no
250
+ # longer streams its dequeue frames, so any leftover remote rows
251
+ # would linger as phantoms: clear them (in place — the panel holds
252
+ # this list object). A PARKED turn is different: the kernel queue is
253
+ # still alive server-side waiting for a wake, so the rows are kept
254
+ # (tagged parked, ⏸ in the panel) instead of wiped — the next run's
255
+ # task_dequeued frames remove them by iid as the kernel drains.
256
+ if self._parked:
257
+ for r in self.remote_pending:
258
+ r["parked"] = True
259
+ self._parked = False
260
+ else:
261
+ self.remote_pending.clear()
231
262
  # Sticky-todo scrollback record (0.3.15): in the dock the live panel
232
263
  # replaced the inline re-renders, so print the FINAL checklist state
233
264
  # ONCE per turn that touched it — the transcript keeps the history
@@ -259,6 +290,23 @@ class RichSink:
259
290
  self.console.print(_pad(Text(_clean(message), style=_BEE)))
260
291
  self._nudge()
261
292
 
293
+ def reconnecting(self, attempt: int, delay: float) -> None:
294
+ """Stream transport down (W1 front-5): attempt>0 arms the toolbar's
295
+ `⟳ reconnecting` state (busy stays true — the steer poller keeps off);
296
+ attempt==0 clears it, printing ONE honest note when the outage lasted
297
+ >300s (the 24h/10k durable stream may have trimmed frames past our
298
+ Last-Event-ID — progress lines may be missing, work is NOT lost)."""
299
+ if attempt:
300
+ if self._reconnect_since is None:
301
+ self._reconnect_since = self._clock()
302
+ self._reconnecting = int(attempt)
303
+ else:
304
+ since, self._reconnect_since = self._reconnect_since, None
305
+ self._reconnecting = 0
306
+ if since is not None and self._clock() - since > 300:
307
+ self.note("⟳ reconnected after a long outage — some progress lines may be missing")
308
+ self._nudge()
309
+
262
310
  def todos(self, items: list, total: int, done: int) -> None:
263
311
  """Checklist state for the coding TODO scratchpad. The kernel
264
312
  republishes the FULL list on every todo_write; each update replaces
@@ -621,7 +669,9 @@ class RichSink:
621
669
  "elapsed": self._elapsed(), "tools": self._tools,
622
670
  "tokens": self.session_tokens + (self.tokens if self._busy else 0),
623
671
  "credits": self.session_credits + (self.credits if self._busy else 0),
624
- "consent": self.consent_pending()}
672
+ "consent": self.consent_pending(),
673
+ "reconnecting": self._reconnecting,
674
+ "turn_failed": self._turn_failed}
625
675
 
626
676
  def is_busy(self) -> bool:
627
677
  return self._busy
@@ -47,7 +47,10 @@ async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
47
47
  sink.end_turn("") # clear busy (poller starvation guard)
48
48
  return ""
49
49
  except Exception as e: # network/auth/etc — never crash
50
- sink.note(f"Error: {type(e).__name__}: {e}")
50
+ if type(e).__name__ in ("StreamAuthError", "NotLoggedInError"):
51
+ sink.note("Session expired or access revoked — run /login to sign in again.")
52
+ else:
53
+ sink.note(f"Error: {type(e).__name__}: {e}")
51
54
  sink.end_turn("") # clear busy: a stuck 'working' also starves the idle-steer poller
52
55
  return ""
53
56
  sink.end_turn(text)
@@ -55,7 +58,7 @@ async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
55
58
 
56
59
 
57
60
  async def _inject_via_gateway(cfg, token_provider, agent, sink,
58
- text: str, steer_iid: str) -> bool:
61
+ text: str, steer_iid: str, client=None) -> bool:
59
62
  """The gateway leg of the dock's Enter-while-busy fly-in (mid-turn inject,
60
63
  0.3.15): POST the line straight into the agent's LIVE running session so
61
64
  the marathon absorbs it at the next brain step (seconds), instead of
@@ -64,13 +67,18 @@ async def _inject_via_gateway(cfg, token_provider, agent, sink,
64
67
  echo drives the panel row. False on ANY failure (no live session yet,
65
68
  network, auth, gateway refusal) — the dock then falls back to today's
66
69
  local type-ahead queue (tui._inject_or_queue), carrying the same iid so
67
- the kernel ring dedups a twin. Module-level so tests drive it directly."""
70
+ the kernel ring dedups a twin. Module-level so tests drive it directly.
71
+ `client=` (Task 12) reuses the repl's shared keep-alive AsyncClient; None
72
+ keeps the per-call client (existing direct tests of this function)."""
68
73
  sid = getattr(agent, "session_id", "")
69
74
  if not sid:
70
75
  return False
71
76
  try:
72
77
  from webbee.thread import inject_to_session
73
- ok = await inject_to_session(cfg, token_provider, sid, text, steer_iid)
78
+ # Old-style test doubles for inject_to_session don't accept a client
79
+ # kwarg -- only pass it when the repl actually gave us one.
80
+ inject_kw = {"client": client} if client is not None else {}
81
+ ok = await inject_to_session(cfg, token_provider, sid, text, steer_iid, **inject_kw)
74
82
  except Exception:
75
83
  return False
76
84
  if ok:
@@ -78,6 +86,26 @@ async def _inject_via_gateway(cfg, token_provider, agent, sink,
78
86
  return ok
79
87
 
80
88
 
89
+ def _gate_busy(sink, turn_ref: dict) -> bool:
90
+ """Pure predicate behind `_poller_busy` (module-level so tests drive it
91
+ directly, unlike the run_repl closure) -- LOCKOUT-PROOF like
92
+ tui._busy_live: busy counts only while the turn TASK recorded in
93
+ `turn_ref` is genuinely alive. A BaseException-class escape (or a raise
94
+ inside end_turn) that leaves the sink's _busy flag stuck must no longer
95
+ starve the idle-steer poller. `turn_ref` is populated ONLY on the dock
96
+ path (the SAME dict object shared into tui.run_session); the fallback
97
+ loop leaves it at {"task": None} forever, so the raw flag governs there
98
+ (its end_turn paths are deterministic)."""
99
+ busy = bool(getattr(sink, "is_busy", None) and sink.is_busy())
100
+ t = turn_ref.get("task")
101
+ if busy and t is not None and t.done():
102
+ busy = False
103
+ if busy:
104
+ return True
105
+ cp = getattr(sink, "consent_pending", None)
106
+ return bool(cp and cp())
107
+
108
+
81
109
  async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None, read_line=input,
82
110
  agent_factory=None, auth=None, account_fetcher=None,
83
111
  sessions_client=None, intel_factory=None, shadow_factory=None) -> None:
@@ -101,6 +129,12 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
101
129
  from webbee.tokens import make_token_provider
102
130
  token_provider = make_token_provider(cfg, auth)
103
131
 
132
+ from webbee import http as _http
133
+ shared_client = None # created in _boot (needs the running loop); Task 12:
134
+ # ONE keep-alive AsyncClient for the poller/inject/
135
+ # thread-replay calls instead of a fresh TCP+TLS
136
+ # handshake per call.
137
+
104
138
  # Prod dock path = the default reader + a real tty + no injected sink; tests
105
139
  # inject sink/read_line and take the plain fallback loop.
106
140
  use_dock = sink is None and read_line is input and sys.stdin.isatty()
@@ -109,6 +143,12 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
109
143
  # and /queue clear (dispatched through CommandContext.queued, same
110
144
  # mechanism /status uses for session state) see the live deque.
111
145
  pending_queue: deque = deque()
146
+ # Shared with tui.run_session on the dock path ONLY (same dict object):
147
+ # the poller's lockout-proof busy gate reads whether the turn TASK
148
+ # recorded here is genuinely alive, mirroring tui._busy_live. The
149
+ # fallback loop never touches this -- it stays {"task": None} and
150
+ # _gate_busy falls back to the raw sink flag there.
151
+ turn_ref: dict = {"task": None}
112
152
  _sink = None # assigned by _boot
113
153
  agent = None # assigned by _boot
114
154
  intel = None # assigned by _boot -- IntelService, or None (off/base-install/boot failure)
@@ -271,8 +311,21 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
271
311
  _sink.end_turn("") # clear busy (poller starvation guard)
272
312
  return
273
313
  except Exception as e: # network/auth/etc — never crash the REPL
274
- _sink.note(f"Error: {type(e).__name__}: {e}")
314
+ # W1 task 6: flag the sink so the dock's drain rule HOLDS the
315
+ # type-ahead queue instead of burning one queued line into this
316
+ # failing turn (getattr-guarded — minimal sinks in tests/headless
317
+ # callers may not implement it).
318
+ _mark = getattr(_sink, "mark_turn_failed", None)
319
+ if _mark is not None:
320
+ _mark()
321
+ if type(e).__name__ in ("StreamAuthError", "NotLoggedInError"):
322
+ _sink.note("Session expired or access revoked — run /login to sign in again.")
323
+ else:
324
+ _sink.note(f"Error: {type(e).__name__}: {e}")
275
325
  _sink.end_turn("") # clear busy: a stuck 'working' also starves the idle-steer poller
326
+ if pending_queue:
327
+ _sink.note(f"⏸ queue held: {len(pending_queue)} queued message(s) wait "
328
+ "— ↑ pulls the next into the input, /queue clear drops them")
276
329
  return
277
330
  _sink.end_turn(text)
278
331
 
@@ -284,15 +337,14 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
284
337
  await _run_turn(text, surface=surface, steer_iid=steer_iid)
285
338
 
286
339
  def _poller_busy() -> bool:
287
- """The idle-steer poller's busy gate: the sink's live turn state PLUS
288
- an armed local prompt (the autopilot confirm arms the same pinned-
289
- input future a consent uses) -- submitting a steer turn under an
290
- armed prompt could double-prompt the input, so the poller holds off
291
- until it resolves. Both hooks getattr-guarded (minimal test sinks)."""
292
- if bool(getattr(_sink, "is_busy", None) and _sink.is_busy()):
293
- return True
294
- cp = getattr(_sink, "consent_pending", None)
295
- return bool(cp and cp())
340
+ """The idle-steer poller's busy gate: the sink's live turn state
341
+ (now LOCKOUT-PROOF via _gate_busy -- a dead turn task overrides a
342
+ stuck busy flag) PLUS an armed local prompt (the autopilot confirm
343
+ arms the same pinned-input future a consent uses) -- submitting a
344
+ steer turn under an armed prompt could double-prompt the input, so
345
+ the poller holds off until it resolves. Both hooks getattr-guarded
346
+ (minimal test sinks)."""
347
+ return _gate_busy(_sink, turn_ref)
296
348
 
297
349
  def _on_mode(mode: str, surface: str) -> None:
298
350
  """Remote coding-mode request (TG/panel → gateway one-shot req_mode →
@@ -342,8 +394,11 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
342
394
  _t.cancel()
343
395
 
344
396
  async def _boot(s) -> None:
345
- nonlocal _sink, agent, intel, watcher_task, shadow, steer_task
397
+ nonlocal _sink, agent, intel, watcher_task, shadow, steer_task, shared_client
346
398
  _sink = s
399
+ # Task 12: the repl-lifetime keep-alive client (needs the running
400
+ # loop, hence created here rather than at the top of run_repl).
401
+ shared_client = _http.make_client(cfg)
347
402
  # Queue-panel single-source dedup (0.3.16): hand the sink the SAME
348
403
  # type-ahead deque tui mutates, so a kernel task_queued echo can
349
404
  # promote a landed local twin (matched by steer_iid) into the one
@@ -377,7 +432,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
377
432
  cfg, token_provider, workspace=workspace, marathon=not once,
378
433
  is_busy=_poller_busy,
379
434
  live_session_id=lambda: getattr(agent, "session_id", ""),
380
- submit=_steer_submit, on_mode=_on_mode))
435
+ submit=_steer_submit, on_mode=_on_mode, client=shared_client))
381
436
 
382
437
  if use_dock:
383
438
  ok = False
@@ -416,10 +471,17 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
416
471
  remote_pending=getattr(_sink, "remote_pending", None),
417
472
  todos=getattr(_sink, "current_todos", None),
418
473
  inject=lambda text, iid: _inject_via_gateway(
419
- cfg, token_provider, agent, _sink, text, iid),
474
+ cfg, token_provider, agent, _sink, text, iid,
475
+ client=shared_client),
476
+ turn=turn_ref,
420
477
  )
421
478
  finally:
422
479
  _cancel_background()
480
+ if shared_client is not None:
481
+ try:
482
+ await shared_client.aclose()
483
+ except Exception:
484
+ pass
423
485
  except Exception:
424
486
  ok = False
425
487
  finally:
@@ -454,3 +516,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
454
516
  return
455
517
  finally:
456
518
  _cancel_background()
519
+ if shared_client is not None:
520
+ try:
521
+ await shared_client.aclose()
522
+ except Exception:
523
+ pass