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.
- {webbee-0.3.16 → webbee-0.3.17}/CHANGELOG.md +34 -0
- {webbee-0.3.16 → webbee-0.3.17}/PKG-INFO +1 -1
- {webbee-0.3.16 → webbee-0.3.17}/pyproject.toml +1 -1
- webbee-0.3.17/src/webbee/__init__.py +1 -0
- webbee-0.3.17/src/webbee/http.py +11 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/store.py +9 -2
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/vectors.py +9 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/watch.py +9 -1
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/output_pane.py +53 -19
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/queue_panel.py +49 -14
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/render.py +56 -6
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/repl.py +84 -17
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/session.py +148 -25
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/steer.py +33 -4
- webbee-0.3.17/src/webbee/stream.py +115 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/thread.py +36 -22
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/todo_panel.py +20 -7
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/tokens.py +14 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/tools.py +10 -1
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/tui.py +124 -27
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_store.py +21 -0
- webbee-0.3.17/tests/test_intel_vectors.py +51 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_watch.py +12 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_marathon.py +5 -2
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_render.py +116 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_repl.py +133 -1
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_session.py +437 -15
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_steer.py +48 -0
- webbee-0.3.17/tests/test_stream.py +389 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_thread.py +69 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_todo_panel.py +24 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_tokens.py +30 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_tools.py +27 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_tui.py +369 -7
- webbee-0.3.16/src/webbee/__init__.py +0 -1
- webbee-0.3.16/src/webbee/stream.py +0 -58
- webbee-0.3.16/tests/test_intel_vectors.py +0 -26
- webbee-0.3.16/tests/test_stream.py +0 -152
- {webbee-0.3.16 → webbee-0.3.17}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/.gitignore +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/LICENSE +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/README.md +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/install.sh +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/account.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/boot.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/cli.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/commands.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/config.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/consent.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/details.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/events.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/frames.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/remote.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/repo.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/sessions.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/src/webbee/update.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/__init__.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_account.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_cli.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_clipboard.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_commands.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_config.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_details.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_events.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_query.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_intel_service.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_packaging.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_repo.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_sessions.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.16 → webbee-0.3.17}/tests/test_update.py +0 -0
- {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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 = (
|
|
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 = (
|
|
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.
|
|
84
|
-
#
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
157
|
-
if
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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)
|
|
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
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
227
|
-
#
|
|
228
|
-
# would linger as phantoms
|
|
229
|
-
# this list object).
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|