@vibelord/lanes 1.0.0 → 1.0.2

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 (56) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +92 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +928 -150
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +62 -5
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +8 -0
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +326 -25
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +158 -6
  17. package/payload/lanes/docs/DESIGN.md +250 -0
  18. package/payload/lanes/docs/INSTALL.md +39 -0
  19. package/payload/lanes/docs/RUNBOOK.md +507 -24
  20. package/payload/lanes/docs/SHARING.md +1 -1
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders/ssh.sh +18 -0
  23. package/payload/lanes/holders.lock +3 -0
  24. package/payload/lanes/install/enable-phone.sh +15 -6
  25. package/payload/lanes/install/enable-web.sh +19 -6
  26. package/payload/lanes/install/host-install.sh +10 -1
  27. package/payload/lanes/install/services.py +30 -3
  28. package/payload/lanes/install.sh +108 -3
  29. package/payload/lanes/libexec/agent_state.py +246 -0
  30. package/payload/lanes/libexec/connection.py +178 -0
  31. package/payload/lanes/libexec/cursor_sync.py +147 -6
  32. package/payload/lanes/libexec/device_sync.py +134 -0
  33. package/payload/lanes/libexec/economy_poll.py +685 -0
  34. package/payload/lanes/libexec/guard.py +27 -1
  35. package/payload/lanes/libexec/lanes_lib.py +436 -36
  36. package/payload/lanes/libexec/notify.py +317 -0
  37. package/payload/lanes/libexec/registrations.py +148 -0
  38. package/payload/lanes/libexec/session_actions.py +53 -22
  39. package/payload/lanes/libexec/sidebar.py +1190 -186
  40. package/payload/lanes/libexec/statusd.py +352 -90
  41. package/payload/lanes/libexec/tokens.py +310 -0
  42. package/payload/lanes/libexec/tokens.sh +11 -0
  43. package/payload/lanes/libexec/view.sh +22 -11
  44. package/payload/lanes/systemd/lanes-economy.service +20 -0
  45. package/payload/lanes/systemd/lanes-notify.service +19 -0
  46. package/payload/lanes/systemd/lanes-sync.service +3 -0
  47. package/payload/lanes/web/README.md +96 -2
  48. package/payload/lanes/web/browser.css +22 -0
  49. package/payload/lanes/web/browser.js +73 -0
  50. package/payload/lanes/web/index.html +2 -1
  51. package/payload/lanes/web/mobile.css +108 -221
  52. package/payload/lanes/web/mobile.js +105 -55
  53. package/payload/lanes/workspaces.example/README.md +9 -3
  54. package/payload/lanes/zellij/config.kdl +128 -19
  55. package/payload/manifest.json +7 -3
  56. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -5,10 +5,18 @@ Shows one workspace's lanes with color, icon and live status. Selecting a lane w
5
5
  target file and signals the viewport supervisor; the zellij layout is never touched.
6
6
  Fully usable from the keyboard (touch/mouse is a bonus):
7
7
  ↑/↓ j/k move · Enter/→/Space open · J/K open next/previous lane · / filter · Esc clear · g/G · r reload
8
- 2 / 3 open the lane under the cursor in the 2nd / 3rd viewport (wide view sessions)
9
- Upload box (bottom): click it or press U (Ctrl-Alt-u from anywhere), then DROP FILES anywhere on the window. A
10
- terminal delivers a drop as a paste of the LOCAL path into the focused pane, so the box must have focus first; the
11
- file itself is then sent by the upload helper on your machine (it only reads folders you allowed there).
8
+ 2 / 3 open the lane under the cursor in the 2nd / 3rd viewport (wide view sessions) · x / × close (asks y/n)
9
+ ? shows every key in the body until the next key · Tab / Shift-Tab open the next / previous lane that needs you
10
+ Attention first: lanes whose agent needs you, is working, or finished unseen sort into sections above the rest; the
11
+ agent glyph sits in column 2, and every row carries an age (agent `since`, else last output). The cursor is a LANE:
12
+ when a lane changes section the cursor goes with it. The pane title (OSC 2) carries the header counts, and a lane that
13
+ starts needing you raises one desktop notification (OSC 9), both inside the frame's one write — no fork.
14
+ Upload strip (one row above the footer): click it or press U (Ctrl-Alt-u from anywhere), then DROP FILES anywhere
15
+ on the window. A terminal delivers a drop as a paste of the LOCAL path into the focused pane, so the strip must have
16
+ focus first; the file itself is then sent by the upload helper on your machine (it only reads folders you allowed
17
+ there). Below it, when there is room, the strip row: the fleet economy line from the opt-in economy.json
18
+ (`$8.5k/16k 53% · F2 S1 · spill 76%`), else which OTHER workspaces need you (`◆1 Worlds`). Column 34 of a lane row is
19
+ one tail cell by priority: stale `?` > starving ⚠ > downgraded ↓ > mostly paid `$`.
12
20
  Truthful under load: a lane statusd could not observe keeps its last glyph, dimmed, with a `?`; the footer line warns
13
21
  when the host is under pressure or statusd has fallen behind (age of $STATE/health, measured with our own clock).
14
22
  Run via libexec/sidebar (a loop), so it never leaves its pane empty.
@@ -23,6 +31,7 @@ import subprocess
23
31
  import sys
24
32
  import termios
25
33
  import time
34
+ from operator import itemgetter
26
35
  from pathlib import Path
27
36
  import tty
28
37
  import unicodedata
@@ -30,12 +39,125 @@ import unicodedata
30
39
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
40
  import lanes_lib as L # noqa: E402
32
41
  import session_actions as A # noqa: E402
42
+ import registrations as R # noqa: E402
43
+ import tokens # noqa: E402
33
44
 
34
45
  STATE = os.environ.get('LANES_STATE') or os.path.expanduser('~/.local/state/lanes')
46
+ # What the harness in a lane is doing (statusd's additive `agent{}`, Task 4/5 vocabulary): `key -> (unicode, ascii,
47
+ # tone)` from the one token source, drawn in column 2 (spec §3). A SEPARATE dict: GLYPHS' keys stay statusd's words.
48
+ AGENT_GLYPHS = tokens.AGENT_GLYPHS
49
+ # The attention queue (spec §4.4): (section key, title, the agent status whose glyph heads the section, members in
50
+ # their order of urgency). Needs you = a blocking prompt, a failure, a finished turn waiting for a prompt.
51
+ ATTENTION = (('needs', 'Needs you', 'needs_approval', ('needs_approval', 'errored', 'needs_input')),
52
+ ('working', 'Working', 'working', ('working',)),
53
+ ('done', 'Done', 'done', ('done',)))
54
+ HEADER_COUNTS = ('needs_approval', 'needs_input', 'working', 'done') # the header's glyph counts (spec §4.3)
55
+ SECTION_OF = {status: key for key, _title, _glyph, members in ATTENTION for status in members}
56
+ NEEDS_RANK = {status: rank for rank, status in enumerate(ATTENTION[0][3])}
57
+ INF = float('inf')
58
+ # Tones by role name. Each Sidebar resolves its own table per device (`apply_look`, spec §2.4); this 16-colour table
59
+ # (the user's own palette) is only the class default for a Sidebar built without `__init__`.
60
+ TONE16 = tokens.sgr_table('16', 'dark')
61
+ THEME_GRACE = 0.3 # seconds to wait for the OSC 11 answer before painting dark (spec §2.4)
62
+ OSC11_QUERY = '\033]11;?\033\\' # "what is your background colour?", written once at start (start_bytes)
63
+ # One complete OSC sequence (ST- or BEL-terminated). handle() strips these before its key loop: a reply is never an Esc.
64
+ OSC = re.compile(r'\033\]([^\033\007]*)(?:\033\\|\007)')
65
+ # The only incomplete OSC worth holding across reads: a prefix of the OSC 11 answer this sidebar asked for. Anything
66
+ # else (an operator's Esc then `]`) is input as before, so a held tail can never swallow later keys.
67
+ # Agent statuses that put a lane in Needs you: Tab cycles them, and entering one raises one OSC 9 (spec §4.5, §4.9).
68
+ NEEDS_YOU = ATTENTION[0][3]
69
+ # What may not ride inside an OSC string: C0, DEL and C1 (an ESC or BEL would end it and start a sequence of its own).
70
+ OSC_UNSAFE = re.compile(r'[\x00-\x1f\x7f-\x9f]')
71
+ OSC_TEXT_MAX = 200 # characters of one title / notification payload
72
+ OSC11_PREFIX = re.compile(r'\033\](?:1(?:1(?:;(?:r(?:g(?:b(?::[0-9A-Fa-f/]{0,14})?)?)?)?)?)?)?(?:\033)?$')
73
+
74
+
75
+ def age3(seconds):
76
+ """Three cells, right-aligned: how long. None/negative = nothing known = three blanks (spec §4.2)."""
77
+ if seconds is None or seconds < 0:
78
+ return ' '
79
+ if seconds < 60:
80
+ return f'{int(seconds):2d}s'
81
+ if seconds < 3600:
82
+ return f'{int(seconds // 60):2d}m'
83
+ if seconds < 86400:
84
+ return f'{int(seconds // 3600):2d}h'
85
+ return f'{min(99, int(seconds // 86400)):2d}d'
86
+
87
+
88
+ def osc_text(text):
89
+ """A string fit for an OSC payload: control characters dropped, at most OSC_TEXT_MAX characters (spec §4.9)."""
90
+ return OSC_UNSAFE.sub('', str(text))[:OSC_TEXT_MAX]
91
+
92
+
93
+ # ----------------------------------------------------------------- economy (plan Task 12, spec §4.2/§4.6(c))
94
+ ECONOMY_SCHEMA = 1 # economy.json `v` this reader understands (Task 11, DM-LANES-ECONOMY; additive)
95
+ STARVING_AGE = 600 # seconds without a request while the pane is producing output
96
+ # economy.json older than this by its own `at`, measured with OUR clock, is a frozen picture (DEF-1447): the poller
97
+ # rewrites the file at least every economy_poll.HEARTBEAT (60 s) even when nothing changed, so three missed heartbeats
98
+ # mean the poller stopped (crashed, disabled, or a host too loaded to run it) — the same own-clock rule as HEALTH_STALE.
99
+ ECONOMY_STALE = 180
100
+ # Tier letters, in strip order (spec §3/§10: the strip row and the Task 15 preview only — never a row at 36 cols).
101
+ # Plain letters, not table glyphs: the same in every glyph mode. The model name decides; an unknown model has none.
102
+ TIERS = (('fable', 'F'), ('opus', 'O'), ('sonnet', 'S'), ('haiku', 'H'), ('gpt', 'G'))
103
+ SPILL_QUIET = ('closed',) # Throttle `pressure.phase` words (verbatim, DM-LANES-ECONOMY) that are not news
104
+
105
+
106
+ def number(value):
107
+ """value when it is a real number (bool is not), else None: economy.json is a projection, never trusted blindly."""
108
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
109
+
110
+
111
+ def starving(lane_status, lane_econ):
112
+ """True when a lane is producing output (`state == 'active'`) while the proxy says it is not being served: no
113
+ request for more than STARVING_AGE s, or a 429 in the daemon's recent-request ring (spec §2.3, plan Task 12).
114
+ `last_request_age_s` is the poller's own measurement at economy.json's `at`; it is NOT aged by the sidebar's clock,
115
+ so a stopped poller freezes the cell instead of turning every active lane into ⚠ — and tail_for() paints a frozen
116
+ figure (a lane record with `stale: true`, or a document past ECONOMY_STALE) ink-dim, not as a live alarm."""
117
+ if not lane_econ or (lane_status or {}).get('state') != 'active':
118
+ return False
119
+ age, refused = number(lane_econ.get('last_request_age_s')), number(lane_econ.get('status_429'))
120
+ return (age is not None and age > STARVING_AGE) or (refused is not None and refused > 0)
121
+
122
+
123
+ def compact_usd(value):
124
+ """Dollars in at most five cells for the strip: 950, 8.5k, 16k, 123k, 1.2M (no trailing `.0`)."""
125
+ value = max(0.0, float(value))
126
+ if value < 999.5:
127
+ return f'{value:.0f}'
128
+ scaled, suffix = (value / 1e3, 'k') if value < 999_500 else (value / 1e6, 'M')
129
+ text = f'{scaled:.1f}' if scaled < 9.95 else f'{scaled:.0f}'
130
+ return text.removesuffix('.0') + suffix
131
+
132
+
133
+ def tier_of(model):
134
+ """The tier letter of a model id (`claude-fable-5-1` -> `F`), or '' when no tier is known."""
135
+ model = model.lower() if isinstance(model, str) else ''
136
+ return next((letter for word, letter in TIERS if word in model), '')
137
+
138
+
139
+ def load_economy(doc):
140
+ """economy.json as the sidebar uses it: the document when it is a schema-1 object, else {} (a document of another
141
+ version, or not an object, is ignored rather than half-read). `fleet` and `lanes` are always dicts afterwards."""
142
+ if not isinstance(doc, dict) or doc.get('v') != ECONOMY_SCHEMA:
143
+ return {}
144
+ fleet, lanes = doc.get('fleet'), doc.get('lanes')
145
+ return dict(doc, fleet=fleet if isinstance(fleet, dict) else {},
146
+ lanes={k: v for k, v in lanes.items() if isinstance(v, dict)} if isinstance(lanes, dict) else {})
147
+
148
+
35
149
  # The six words statusd publishes (alignment contract A3) and the helper word for an `exec` lane. There is no
36
- # seventh: statusd's zmx observer used to publish `idle`, which no other surface knew (DEF-823).
37
- GLYPHS = {'active': ('●', '32'), 'quiet': ('○', '2'), 'missing': ('✖', '31'), 'absent': ('–', '2'),
38
- 'dead': ('!', '33'), 'helper': ('·', '2'), 'unknown': ('?', '2')}
150
+ # seventh: statusd's zmx observer used to publish `idle`, which no other surface knew (DEF-823). Values are
151
+ # (unicode, ascii, tone) from the one token source — the tone is a role name from tokens.ROLES, never an SGR code:
152
+ # the device's mode decides the bytes (`apply_look`). The keys stay pinned by the vocabulary gate.
153
+ GLYPHS = tokens.GLYPHS
154
+ GLYPHS_ASCII = {key: value[1] for key, value in GLYPHS.items()}
155
+ MARKS = tokens.MARKS
156
+ ECONOMY_TAIL = tokens.ECONOMY_TAIL
157
+ MOBILE_AGENT_LABELS = tokens.MOBILE_AGENT_LABELS
158
+ # The phone header's words after each glyph count (spec §4.10): ` ◆1 need approval · ◇2 need you · ▸6 working`.
159
+ # Keyed by HEADER_COUNTS, the statuses the desktop header counts.
160
+ MOBILE_COUNT_WORDS = {'needs_approval': 'need approval', 'needs_input': 'need you', 'working': 'working', 'done': 'done'}
39
161
  # The phone sidebar spells the same six words (plus `helper`) in full. The keys are GLYPHS's keys and the
40
162
  # vocabulary gate pins that: this map kept `idle` for a whole release after DEF-823 removed it from GLYPHS,
41
163
  # because the gate read GLYPHS alone and the map was a literal buried inside `render_mobile` (DEF-983).
@@ -44,6 +166,44 @@ MOBILE_LABELS = {'active': 'Active', 'quiet': 'Quiet', 'missing': 'Container off
44
166
  'unknown': 'Checking status'}
45
167
 
46
168
 
169
+ def resolve_modes(env, meta):
170
+ """(color, theme, glyphs) from the device file first, then the environment, then the defaults of spec §2.4.
171
+ Pure; an unknown value falls back to the default rather than raising (a hand-edited file must not kill a pane)."""
172
+ meta = meta if isinstance(meta, dict) else {}
173
+ if env.get('NO_COLOR'):
174
+ color = 'none'
175
+ else:
176
+ color = env.get('LANES_COLOR') or ('truecolor' if env.get('COLORTERM') in ('truecolor', '24bit') else '16')
177
+ color = meta.get('color') or color
178
+ theme = meta.get('theme') or env.get('LANES_THEME') or 'auto'
179
+ locale = [env.get(k) or '' for k in ('LC_ALL', 'LC_CTYPE', 'LANG')]
180
+ utf8 = any('UTF-8' in value.upper() or 'UTF8' in value.upper() for value in locale)
181
+ glyphs = meta.get('glyphs') or env.get('LANES_GLYPHS') or ('unicode' if utf8 or not any(locale) else 'ascii')
182
+ if color not in ('truecolor', '16', 'none'):
183
+ color = '16'
184
+ if theme not in ('dark', 'light', 'auto'):
185
+ theme = 'auto'
186
+ if glyphs not in ('unicode', 'ascii', 'emoji'):
187
+ glyphs = 'unicode'
188
+ return color, theme, glyphs
189
+
190
+
191
+ def parse_osc11(body):
192
+ """'11;rgb:RRRR/GGGG/BBBB' (1- to 4-digit hex components) -> 'dark'|'light' by relative luminance (< 0.5 dark);
193
+ anything else -> None."""
194
+ if not body.startswith('11;rgb:'):
195
+ return None
196
+ parts = body[7:].split('/')
197
+ if len(parts) != 3 or not all(1 <= len(p) <= 4 for p in parts):
198
+ return None
199
+ try:
200
+ channels = [int(p, 16) / (16 ** len(p) - 1) for p in parts]
201
+ except ValueError:
202
+ return None
203
+ lin = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in channels]
204
+ return 'light' if 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] >= 0.5 else 'dark'
205
+
206
+
47
207
  def viewer_mark(status):
48
208
  """The mark after a lane's name when other sessions view it: one dot per viewer, at most three."""
49
209
  count = int((status or {}).get('viewers') or 0)
@@ -66,6 +226,8 @@ def mtime(path):
66
226
 
67
227
 
68
228
  def cell_width(text):
229
+ if text.isascii(): # every ASCII character is one cell here (none combines, none is wide): the common case
230
+ return len(text)
69
231
  width = 0
70
232
  for ch in text:
71
233
  if unicodedata.combining(ch) or ch in '‍️':
@@ -75,6 +237,9 @@ def cell_width(text):
75
237
 
76
238
 
77
239
  def clip(text, width):
240
+ if text.isascii(): # same answer as the walk below, without it (spec §4.11: one clip per row, cheap)
241
+ text = text[:max(0, width)]
242
+ return text, len(text)
78
243
  out, used = [], 0
79
244
  for ch in text:
80
245
  w = cell_width(ch)
@@ -88,14 +253,16 @@ def clip(text, width):
88
253
  HEALTH_STALE = 30 # seconds; statusd rewrites $STATE/health every cycle, at most ~12 s apart when it slows itself down
89
254
 
90
255
 
91
- def health_banner(health, written, now):
256
+ def health_banner(health, written, now, warning='⚠', sep=' · '):
92
257
  """The footer warning; '' = nothing to say. `health` = L.read_health(), `written` = the file's mtime in seconds
93
258
  (0 = no such file: a statusd from before it existed — nothing is known, so nothing is claimed).
94
259
  statusd is what freezes on a thrashing host, so the age is measured HERE, with our clock, from the file's mtime.
95
- The age comes first: a sidebar is 36 cells wide, and "what you see is N minutes old" must survive the clipping."""
260
+ The age comes first: a sidebar is 36 cells wide, and "what you see is N minutes old" must survive the clipping.
261
+ `warning` and `sep` are the device's `warning` / ` sep ` marks (spec §4.6(a)); the defaults are the unicode ones,
262
+ so a three-argument call reads exactly as before."""
96
263
  if os.path.exists(os.path.join(STATE, 'flags', 'sockets-lost')):
97
264
  # Short, and first: it explains every "not running" on the screen. The fix is one command on the server.
98
- return '⚠ lane sockets deleted — lanes run; on the server: lanes sockets repair'
265
+ return f'{warning} lane sockets deleted{sep}lanes run; on the server: lanes sockets repair'
99
266
  if not written:
100
267
  return ''
101
268
  parts, age = [], int(now - written)
@@ -104,18 +271,76 @@ def health_banner(health, written, now):
104
271
  level = health.get('level', 'ok')
105
272
  if level != 'ok':
106
273
  parts.append('host under memory pressure' if level == 'critical' else 'host busy')
107
- return '⚠ ' + ' · '.join(parts) if parts else ''
274
+ return warning + ' ' + sep.join(parts) if parts else ''
108
275
 
109
276
 
110
277
  SLOTS = ['main', 'b', 'c']
111
- BOX_ROWS = 4
278
+ BOX_ROWS = 1 # the upload strip (spec §4.6(d)); it was a four-row box
279
+ # The `?` key sheet (spec §4.7): (key, its seven-bit twin, action), one line per key handle() accepts. A line is
280
+ # ` {key:<10}{action}`: the key field is 10 cells and an action at most 23, so a line is at most 35 cells — the
281
+ # sidebar's row width with column 36 never written. `Tab`/`Shift-Tab` are Task 25's keys.
282
+ KEY_SHEET = (('↑ ↓ j k', 'Up Dn j k', 'move'), ('⏎ → Space', 'Enter Spc', 'open the cursor lane'),
283
+ ('J K', 'J K', 'open next / prev lane'), ('Tab', 'Tab', 'next lane needing you'),
284
+ ('Shift-Tab', 'Shift-Tab', 'prev lane needing you'), ('2 3', '2 3', 'show in viewport 2 / 3'),
285
+ ('/', '/', 'find a lane'), ('Esc', 'Esc', 'clear / cancel'), ('g G', 'g G', 'first / last'),
286
+ ('+', '+', 'new shell'), ('a', 'a', 'add to a workspace'), ('x', 'x', 'close (asks y/n)'),
287
+ ('R', 'R', 'rename'), ('P', 'P', 'register a session'), ('U', 'U', 'arm the upload strip'),
288
+ ('r', 'r', 'reload'), ('?', '?', 'this sheet'))
112
289
  PASTE_START, PASTE_END = '\033[200~', '\033[201~'
113
290
 
114
291
 
292
+ def key_length(data, i):
293
+ """How many characters of `data` from `i` make ONE key: a plain character, a bare Esc, an SS3 key (`\\033OA`), an
294
+ SGR mouse report (`\\033[<b;x;yM`/`m`) or any other CSI up to its final byte (`\\033[A`, `\\033[5~`). Esc followed
295
+ by anything else is a bare Esc, as handle() reads it. An unterminated sequence runs to the end of the read, which
296
+ is how handle() itself drops one."""
297
+ if data[i] != '\033':
298
+ return 1
299
+ rest = data[i + 1:]
300
+ if rest.startswith('[<'):
301
+ end = next((k for k, c in enumerate(rest) if c in 'Mm'), -1)
302
+ elif rest[:1] == '[':
303
+ end = next((k for k, c in enumerate(rest[1:], 1) if '@' <= c <= '~'), -1)
304
+ elif rest[:1] == 'O' and len(rest) > 1:
305
+ return 3
306
+ else:
307
+ return 1
308
+ return len(data) - i if end < 0 else end + 2
309
+
310
+
115
311
  class Sidebar:
312
+ # Class-level defaults for the registration state, so a Sidebar built without `__init__` (the
313
+ # alignment gate constructs one with `__new__` and sets only the fields its rule needs) reads the
314
+ # 'no registration in progress' shape rather than raising; `__init__` assigns the live values.
315
+ registration, registration_choices, workspaces = None, (), ()
316
+ renaming, rename_value, rename_entry = False, '', None
317
+ promoting, promote_entry = False, None
318
+ attach_only = False
319
+ confirming = None # the entry `x` / `×` asked to close; `y` closes it, any other key cancels
320
+ seen = {} # read-only default; mark_seen() always assigns a fresh dict
321
+ tone = TONE16
322
+ glyphs = tokens.glyph_set('unicode')
323
+ color, glyph_mode, variant = '16', 'unicode', 'dark'
324
+ theme_pending, theme_deadline, osc_buffer = False, 0.0, ''
325
+ last_visible = () # the list visible() last returned: the one `self.cursor` indexes
326
+ economy = {} # read-only default; reload() assigns the loaded economy.json (load_economy)
327
+ economy_tiers = '' # this workspace's tier mix from economy.json, `F2 S1` (reload)
328
+ more_row = 0 # the 1-based screen row of `⋮ N more` (render), 0 = the list fits
329
+ showing_keys = False # `?` replaces the body with the key sheet until the next key (spec §4.7)
330
+ fleet = {} # other workspaces' title -> {needs_approval: n, needs_input: n} (reload)
331
+ board_titles = {} # workspace id -> title, from the registry (reload)
332
+ strip_row = False # render(): the strip row (economy or fleet attention) is drawn this frame
333
+ last_counts = '' # the header counts the last OSC 2 title carried ('' = none sent; spec §4.9)
334
+ notified = {} # read-only default; __init__ assigns: lane id -> the needs-you status already notified
335
+
116
336
  def __init__(self, device, workspace, slot='main', viewports='1'):
117
337
  self.device, self.workspace, self.slot = device, workspace, slot
118
- self.mobile = bool((read_json(os.path.join(STATE, 'devices', device + '.json')) or {}).get('mobile'))
338
+ # devices/<device>.json: `mobile` is read once here; the look keys (`color`, `theme`, `glyphs`) are re-read
339
+ # whenever the file changes (reload()'s stamp), so `lanes attach DEVICE --ascii` reaches a RUNNING sidebar.
340
+ self.look_path = os.path.join(STATE, 'devices', device + '.json')
341
+ meta = read_json(self.look_path)
342
+ meta = meta if isinstance(meta, dict) else {}
343
+ self.mobile = bool(meta.get('mobile'))
119
344
  self.mobile_owner, self.mobile_titles, self.next_mobile_poll = False, {}, 0
120
345
  try:
121
346
  self.viewports = max(1, min(len(SLOTS), int(viewports)))
@@ -126,16 +351,35 @@ class Sidebar:
126
351
  self.pid_file = self.slot_file(slot, 'pid')
127
352
  self.registry_path = os.path.join(STATE, 'registry.json')
128
353
  self.status_path = os.path.join(STATE, 'status.json')
354
+ self.economy_path = os.path.join(STATE, 'economy.json') # the opt-in poller's projection (Task 11)
129
355
  self.health_path = os.path.join(STATE, 'health')
130
356
  self.health, self.health_stamp, self.banner = {}, None, ''
131
357
  self.entries, self.title, self.errors = [], workspace, []
358
+ self.attach_only = False
359
+ self.workspaces, self.registration, self.registration_choices = [], None, []
132
360
  self.status, self.unregistered, self.zmx = {}, [], []
133
361
  self.cursor, self.top = 0, 0
134
362
  self.filter, self.filtering = '', False
135
363
  self.renaming, self.rename_value, self.rename_entry = False, '', None
136
364
  self.promoting, self.promote_entry = False, None
137
- self.stamp = (0, 0, 0)
138
- self._prev_entry_count = -1 # for scroll memory
365
+ self.confirming = None
366
+ self.dirty = True
367
+ self.color, self.glyph_mode, self.variant = None, None, None
368
+ self.theme_pending, self.theme_deadline, self.osc_buffer = False, 0.0, ''
369
+ self.row_cache = {} # lane id -> (key, row text); a look change and `r` empty it
370
+ self.row_hits, self.row_misses = 0, 0
371
+ self.economy, self.economy_tiers = {}, '' # economy.json (reload); tail_for() and the strip read it
372
+ self.economy_frozen = False # economy_aged() at the last poll_economy(): a flip repaints
373
+ self.showing_keys, self.strip_row = False, False
374
+ self.fleet, self.board_titles = {}, {}
375
+ self.last_counts, self.notified = '', {} # '' not None: an agent-less workspace never emits a title
376
+ self.apply_look(meta)
377
+ # `done` is sticky until the lane is opened from this device: lane id -> the time it was last opened. Read
378
+ # once here and again only when a target file changes (never per tick: spec §8's stat budget), written by
379
+ # mark_seen() and by `lanes next|prev`.
380
+ self.seen_path = os.path.join(STATE, 'clients', device + '.seen.json')
381
+ self.seen = L.clean_seen(read_json(self.seen_path))
382
+ self.stamp = (0, 0, 0, 0, 0) # registry, status, targets, device look, economy
139
383
  self.size = (0, 0)
140
384
  self.dirty = True
141
385
  self.message = ''
@@ -158,34 +402,124 @@ class Sidebar:
158
402
  # ---------------------------------------------------------------- data
159
403
  def reload(self, force=False):
160
404
  stamp = (mtime(self.registry_path), mtime(self.status_path),
161
- sum(mtime(self.slot_file(slot, 'target')) for slot in SLOTS[:self.viewports]))
405
+ sum(mtime(self.slot_file(slot, 'target')) for slot in SLOTS[:self.viewports]),
406
+ mtime(self.look_path), mtime(self.economy_path))
162
407
  if stamp == self.stamp and not force:
163
408
  return
409
+ if force:
410
+ self.row_cache.clear() # `r` and every forced reload repaint every row from scratch (spec §4.11)
411
+ # The cursor follows the LANE, not the index (spec §4.5, §10): a lane that changes section moves rows above
412
+ # the cursor, so remember which lane it is on and re-find it after the reload.
413
+ cursor_id = self.cursor_id
164
414
  if stamp[0] != self.stamp[0] or force:
165
415
  registry = read_json(self.registry_path) or {}
166
416
  boards = registry.get('workspaces', [])
417
+ self.workspaces = boards
167
418
  self.mobile_owner = self.mobile and bool(boards) and boards[0]['id'] == self.workspace
168
419
  self.mobile_titles = {w.get('title') or w['id']: w['id'] for w in boards}
420
+ self.board_titles = {w['id']: w.get('title') or w['id'] for w in boards}
169
421
  self.mobile_titles.update({tool['tab']: tool['view'] for tool in registry.get('tools', [])})
170
422
  for workspace in registry.get('workspaces', []):
171
423
  if workspace.get('id') == self.workspace:
172
424
  self.title = workspace.get('title') or self.workspace
173
- self.entries = list(workspace.get('lanes', [])) + list(workspace.get('extras', []))
425
+ self.entries = list(workspace.get('lanes', []))
174
426
  self.errors = workspace.get('errors', [])
427
+ self.attach_only = bool(workspace.get('attach_only'))
175
428
  break
176
429
  if stamp[1] != self.stamp[1] or force:
177
430
  status = read_json(self.status_path) or {}
178
431
  self.status = status.get('lanes', {})
179
432
  self.unregistered = (status.get('unregistered') or {}).get(self.workspace, [])
180
433
  self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
434
+ if stamp[0] != self.stamp[0] or stamp[1] != self.stamp[1] or force:
435
+ self.fleet = self.fleet_counts()
436
+ if stamp[3] != self.stamp[3] or force:
437
+ self.apply_look(read_json(self.look_path) or {})
438
+ if stamp[4] != self.stamp[4] or force:
439
+ # The seventh stat() of a tick at one viewport (spec §8): re-read only when the poller replaced the file.
440
+ # A missing file (no poller: the default) is {} — no economy tail on any row, the strip row yields.
441
+ self.economy = load_economy(read_json(self.economy_path)) if stamp[4] else {}
442
+ self.economy_tiers = self.tier_mix()
443
+ if stamp[2] != self.stamp[2] and self.stamp != (0, 0, 0, 0, 0): # not the first reload: __init__ read it
444
+ # A viewport was pointed at a lane — by this sidebar, or by `lanes next|prev` (Ctrl-Alt-Down/Up, plan
445
+ # Task 19), which marks the lane seen in the device's file as open_lane does. Merge the file once per
446
+ # such change (never per tick: spec §8), so a `done` lane opened that way leaves Done here too.
447
+ merged = L.clean_seen(read_json(self.seen_path))
448
+ for key, value in self.seen.items():
449
+ merged[key] = max(value, merged.get(key, value))
450
+ self.seen = merged # a fresh dict, never the class default mutated in place
181
451
  self.stamp = stamp
182
452
  self.dirty = True
183
- # Scroll memory: preserve scroll top when list length is stable.
184
- # Cursor is already clamped by render() on every draw.
185
- new_count = len(self.visible())
186
- if new_count != self._prev_entry_count:
187
- self._prev_entry_count = new_count
188
- self.top = 0 # reset scroll position on structural change
453
+ # `top` is kept (no reset when the list length changes) and shifted by the rows inserted above the cursor, so
454
+ # the cursor's lane stays on its screen row; render() still clamps both on every draw.
455
+ self.resolve_cursor(self.visible(), cursor_id)
456
+
457
+ # ------------------------------------------------------------------ look
458
+ def apply_look(self, meta):
459
+ """Resolve colour / theme / glyph modes from the device file and the environment (spec §2.4). Runs at start and
460
+ whenever devices/<device>.json changes, so `lanes attach DEVICE --ascii|--theme|--color` against a LIVE session
461
+ reaches every sidebar of that device within one tick (Zellij panes keep the server's environment from session
462
+ creation: an environment variable can never reach a running sidebar). Publishes the glyph mode for view.sh."""
463
+ color, theme, glyphs = resolve_modes(os.environ, meta)
464
+ changed = (color, glyphs) != (self.color, self.glyph_mode)
465
+ self.color, self.glyph_mode = color, glyphs
466
+ variant_changed = False
467
+ if theme != 'auto':
468
+ variant_changed = theme != self.variant
469
+ self.variant, self.theme_pending = theme, False
470
+ elif self.variant is None: # first resolution under `auto`: paint dark, ask the terminal once
471
+ self.variant, self.theme_pending, self.theme_deadline = 'dark', True, time.monotonic() + THEME_GRACE
472
+ changed = changed or variant_changed
473
+ self.tone = tokens.sgr_table(self.color, self.variant)
474
+ self.glyphs = tokens.glyph_set(self.glyph_mode)
475
+ glyphs_file = os.path.join(STATE, 'clients', self.device + '.glyphs')
476
+ if changed or not os.path.exists(glyphs_file):
477
+ try:
478
+ L.atomic_write(Path(glyphs_file), self.glyph_mode + '\n')
479
+ except OSError:
480
+ pass # view.sh then keeps its default; the sidebar itself is unaffected
481
+ if theme != 'auto':
482
+ self.publish_theme(variant_changed)
483
+ if changed:
484
+ self.row_cache.clear()
485
+ self.dirty = True
486
+
487
+ def apply_theme(self, variant):
488
+ """The OSC 11 answer (or the grace expiry, with the current variant): repaint on a change and publish it."""
489
+ self.theme_pending = False
490
+ if not variant:
491
+ return
492
+ changed = variant != self.variant
493
+ if changed:
494
+ self.variant, self.tone, self.dirty = variant, tokens.sgr_table(self.color, variant), True
495
+ self.row_cache.clear()
496
+ self.publish_theme(changed)
497
+
498
+ def publish_theme(self, changed):
499
+ """clients/<device>.theme (`dark`/`light` + newline) for `glow`: written on change or when absent, never per
500
+ render."""
501
+ theme_file = os.path.join(STATE, 'clients', self.device + '.theme')
502
+ if changed or not os.path.exists(theme_file):
503
+ try:
504
+ L.atomic_write(Path(theme_file), self.variant + '\n')
505
+ except OSError:
506
+ pass
507
+
508
+ def strip_osc(self, data):
509
+ """Remove complete OSC sequences from `data`, apply OSC 11 answers, hold an incomplete OSC 11 tail for the next
510
+ read (Review Focus 6). An OSC reply is never an Esc: it cannot cancel a filter or a prompt."""
511
+ data, self.osc_buffer = self.osc_buffer + data, ''
512
+ if '\033]' not in data:
513
+ return data
514
+ for match in OSC.finditer(data):
515
+ variant = parse_osc11(match.group(1))
516
+ if variant:
517
+ self.apply_theme(variant)
518
+ data = OSC.sub('', data)
519
+ held = OSC11_PREFIX.search(data)
520
+ if held:
521
+ self.osc_buffer, data = data[held.start():], data[:held.start()]
522
+ return data
189
523
 
190
524
  def poll_mobile_view(self):
191
525
  """One sidebar observes the phone's web client and the first display of each full-width view.
@@ -223,10 +557,25 @@ class Sidebar:
223
557
  stamp = mtime(self.health_path)
224
558
  if stamp != self.health_stamp:
225
559
  self.health_stamp, self.health = stamp, L.read_health(STATE)
226
- banner = health_banner(self.health, stamp / 1e9, time.time())
560
+ marks = self.glyphs['marks']
561
+ banner = health_banner(self.health, stamp / 1e9, time.time(), marks['warning'], f" {marks['sep']} ")
227
562
  if banner != self.banner:
228
563
  self.banner, self.dirty = banner, True
229
564
 
565
+ def economy_aged(self, now):
566
+ """True when the loaded economy.json is older than ECONOMY_STALE by its own `at` against `now` (our clock):
567
+ the poller stopped, so every figure in it is frozen (DEF-1447). False without a document or without a numeric
568
+ `at` — nothing is known, so nothing is claimed (health_banner's rule for a missing health file)."""
569
+ at = number(self.economy.get('at')) if self.economy else None
570
+ return at is not None and now - at > ECONOMY_STALE
571
+
572
+ def poll_economy(self):
573
+ """Repaint when economy.json crosses ECONOMY_STALE. Polled every tick like poll_health(): the case that matters
574
+ is a poller that stopped, where economy.json's mtime — reload()'s only trigger — never changes again."""
575
+ frozen = self.economy_aged(time.time())
576
+ if frozen != self.economy_frozen:
577
+ self.economy_frozen, self.dirty = frozen, True
578
+
230
579
  def current_target(self):
231
580
  try:
232
581
  with open(self.target_file) as handle:
@@ -238,16 +587,417 @@ class Sidebar:
238
587
  except OSError:
239
588
  return ''
240
589
 
590
+ # ------------------------------------------------------------ attention
591
+ def agent_of(self, lane_id):
592
+ """The lane's `agent{}` from status.json, or {} (a plain shell, or a statusd that does not publish it)."""
593
+ agent = (self.status.get(lane_id) or {}).get('agent')
594
+ return agent if isinstance(agent, dict) else {}
595
+
596
+ def seen_done(self, lane_id):
597
+ """True when this lane's `done` is not news any more: it was opened at or after the turn finished."""
598
+ since = self.agent_of(lane_id).get('since')
599
+ return lane_id in self.seen and (since if isinstance(since, (int, float)) else 0) <= self.seen[lane_id]
600
+
601
+ def mark_seen(self, lane_id):
602
+ """Record that the operator opened this lane (only `open_lane` calls it, spec §10): a `done` turn that
603
+ finished before now leaves the Done section; a later one is news again. Persisted per device, bounded to
604
+ the lanes status.json knows, so a restarted sidebar does not resurrect what was already looked at.
605
+ Every workspace tab of a device runs its own sidebar over the SAME file, so a write merges what is on disk
606
+ (the later mark per lane wins) instead of replacing it with this process's view — `lanes_lib.record_seen`,
607
+ which `lanes next|prev` (Ctrl-Alt-Down/Up) call too."""
608
+ since = self.agent_of(lane_id).get('since')
609
+ self.seen = L.record_seen(self.seen_path, lane_id, since, self.status, self.seen)
610
+
611
+ def agent_rows(self, lanes):
612
+ """[(entry, agent status or None, since or None, attention key or None)] in ONE pass over status.json — the
613
+ sections, the counts and the header all read this, so a render looks each lane up once (spec §4.11)."""
614
+ if not lanes:
615
+ return []
616
+ status_of, seen, out = self.status, self.seen, []
617
+ for entry in lanes:
618
+ agent = (status_of.get(entry.get('id', '')) or {}).get('agent')
619
+ status = agent.get('status') if isinstance(agent, dict) else None
620
+ if status not in AGENT_GLYPHS:
621
+ out.append((entry, None, None, None))
622
+ continue
623
+ since = agent.get('since')
624
+ since = since if isinstance(since, (int, float)) else None
625
+ key = SECTION_OF.get(status)
626
+ if key == 'done' and entry['id'] in seen and (since or 0) <= seen[entry['id']]:
627
+ key = None # opened since it finished: not news (seen_done)
628
+ out.append((entry, status, since, key))
629
+ return out
630
+
631
+ def fleet_counts(self):
632
+ """{other workspace's title: {needs_approval: n, needs_input: n}} from the status.json already loaded — one pass
633
+ over its lanes, zero extra reads (spec §4.6(c)); this workspace is never in it. Ordered as the registry lists the
634
+ workspaces, so the strip does not reshuffle when status.json does."""
635
+ found = {}
636
+ for lane_id, entry in self.status.items():
637
+ board = lane_id.split('--', 1)[0]
638
+ agent = entry.get('agent') if isinstance(entry, dict) else None
639
+ status = agent.get('status') if isinstance(agent, dict) else None
640
+ if board != self.workspace and status in ('needs_approval', 'needs_input'):
641
+ counts = found.setdefault(board, {})
642
+ counts[status] = counts.get(status, 0) + 1
643
+ order = {board: index for index, board in enumerate(self.board_titles)}
644
+ return {self.board_titles.get(board, board): found[board]
645
+ for board in sorted(found, key=lambda b: (order.get(b, len(order)), b))}
646
+
647
+ def fleet_attention(self, width=None):
648
+ """The strip row's fleet attention: ` ◆1 Worlds · ◇2 Code`, glyphs in their tones, '' when no other workspace
649
+ needs you. `width` (cells) drops whole workspaces that would not fit, never half a count."""
650
+ glyphs, tone = self.glyphs, self.tone
651
+ sep = f' {glyphs["marks"]["sep"]} '
652
+ out, used = [], 0
653
+ for title, counts in self.fleet.items():
654
+ cells = [(f'{tone[tokens.tone_of(AGENT_GLYPHS, k)]}{glyphs["agent"][k]}{counts[k]}{tone["reset"]}',
655
+ f'{glyphs["agent"][k]}{counts[k]}') for k in ('needs_approval', 'needs_input') if counts.get(k)]
656
+ if not cells:
657
+ continue
658
+ plain = ' '.join(c[1] for c in cells) + ' ' + title
659
+ cost = cell_width((sep if out else ' ') + plain)
660
+ if width is not None and used + cost > width:
661
+ break
662
+ out.append(' '.join(c[0] for c in cells) + ' ' + title)
663
+ used += cost
664
+ return ' ' + sep.join(out) if out else ''
665
+
666
+ def tier_mix(self):
667
+ """This workspace's lanes in economy.json by model tier, in TIERS order: `F2 S1` ('' when none is known).
668
+ Computed once per economy.json change (reload), never per render."""
669
+ prefix, counts = self.workspace + '--', {}
670
+ for lane_id, econ in (self.economy.get('lanes') or {}).items():
671
+ letter = tier_of(econ.get('model')) if lane_id.startswith(prefix) else ''
672
+ if letter:
673
+ counts[letter] = counts.get(letter, 0) + 1
674
+ return ' '.join(f'{letter}{counts[letter]}' for _word, letter in TIERS if counts.get(letter))
675
+
676
+ def economy_strip(self, width, now):
677
+ """(painted, plain) of the strip row's fleet economy line (spec §4.6(c)): ` $8.5k/16k 53% · 429 +4 · F2 S1 ·
678
+ spill 76% · free 3/19 · reset 2h`, parts in that priority order, a part only when its facts are known, whole
679
+ parts dropped from the end to fit `width` cells. ('', '') when economy.json knows no fleet fact (no poller,
680
+ or `LANES_THROTTLE_CONTROL=none`): the strip row then yields to fleet attention. Money over the alert level,
681
+ new 429s and a spilling pool are in the attention tone; everything else, and the whole line while the poller
682
+ marks the fleet `stale` (a frozen picture is not live), ink-dim with the stale mark in front."""
683
+ fleet = self.economy.get('fleet') or {}
684
+ # Frozen two ways: the poller kept its last fleet strip (`/status` down), or the poller itself stopped and the
685
+ # whole document is older than ECONOMY_STALE by our clock (DEF-1447). Both are painted alike.
686
+ stale = fleet.get('stale') is True or self.economy_aged(now)
687
+ spend, budget = number(fleet.get('overflow_spend_today_usd')), number(fleet.get('overflow_budget_usd'))
688
+ fraction = number(fleet.get('spent_fraction'))
689
+ if fraction is None and spend is not None and budget:
690
+ fraction = spend / budget
691
+ alert = number(fleet.get('alert_level_pct'))
692
+ parts = [] # (text, role) in priority order
693
+ if spend is not None:
694
+ text = '$' + compact_usd(spend) + (f'/{compact_usd(budget)}' if budget is not None else '')
695
+ text += f' {round(fraction * 100)}%' if fraction is not None else ''
696
+ parts.append((text, 'tone-attention' if alert is not None and fraction is not None
697
+ and fraction * 100 >= alert else 'ink-dim'))
698
+ new_429 = number(fleet.get('upstream_429_delta'))
699
+ if new_429 is not None and new_429 > 0: # the poller reports growth only (None after a daemon restart)
700
+ parts.append((f'429 +{new_429}', 'tone-attention'))
701
+ has_fleet_fact = bool(parts)
702
+ spill, phase = number(fleet.get('spill_fraction')), fleet.get('pressure_phase')
703
+ free, accounts = number(fleet.get('effective_free')), number(fleet.get('accounts'))
704
+ reset = number(fleet.get('soonest_reset_5h'))
705
+ later = []
706
+ if spill is not None:
707
+ later.append((f'spill {round(spill * 100)}%',
708
+ 'tone-attention' if isinstance(phase, str) and phase not in SPILL_QUIET else 'ink-dim'))
709
+ if free is not None:
710
+ later.append((f'free {free}' + (f'/{accounts}' if accounts is not None else ''), 'ink-dim'))
711
+ if reset is not None and reset > now:
712
+ later.append((f'reset {age3(reset - now).strip()}', 'ink-dim'))
713
+ if not (has_fleet_fact or later):
714
+ return '', ''
715
+ if self.economy_tiers:
716
+ parts.append((self.economy_tiers, 'ink-dim'))
717
+ parts += later
718
+ glyphs, tone = self.glyphs, self.tone
719
+ sep_plain = f' {glyphs["marks"]["sep"]} '
720
+ lead = f' {glyphs["marks"]["stale"]} ' if stale else ' '
721
+ painted, plain, used = [], [], cell_width(lead)
722
+ for text, role in parts:
723
+ cost = cell_width(text) + (cell_width(sep_plain) if plain else 0)
724
+ if used + cost > width:
725
+ break
726
+ role = 'ink-dim' if stale else role
727
+ painted.append(f'{tone[role]}{text}{tone["reset"]}')
728
+ plain.append(text)
729
+ used += cost
730
+ if not plain:
731
+ return '', ''
732
+ sep_painted = f'{tone["ink-dim"]}{sep_plain}{tone["reset"]}'
733
+ lead_painted = f'{tone["ink-dim"]}{lead}{tone["reset"]}' if stale else lead
734
+ return lead_painted + sep_painted.join(painted), lead + sep_plain.join(plain)
735
+
736
+ def attention(self, entry):
737
+ """The attention section key of a registry entry, or None (the remainder)."""
738
+ return self.agent_rows([entry])[0][3]
739
+
740
+ def counts(self):
741
+ """{needs, working, agents, shells} over this workspace's registry lanes: `needs` and `working` are the
742
+ sizes of those sections, `agents` the lanes running a harness (any status but `shell`), `shells` the rest."""
743
+ needs = working = agents = 0
744
+ for _entry, status, _since, key in self.agent_rows(self.entries):
745
+ needs += key == 'needs'
746
+ working += key == 'working'
747
+ agents += status is not None and status != 'shell'
748
+ return {'needs': needs, 'working': working, 'agents': agents, 'shells': len(self.entries) - agents}
749
+
750
+ def header_tally(self):
751
+ """[(agent status, 'glyph+count')] for the header, in `HEADER_COUNTS` order, zeros omitted; a seen `done`
752
+ is not counted (it left the Done section)."""
753
+ counts = {}
754
+ for _entry, status, _since, key in self.agent_rows(self.entries):
755
+ if status in HEADER_COUNTS and (status != 'done' or key):
756
+ counts[status] = counts.get(status, 0) + 1
757
+ agent = self.glyphs['agent']
758
+ return [(k, f'{agent[k]}{counts[k]}') for k in HEADER_COUNTS if counts.get(k)]
759
+
760
+ def header_counts(self):
761
+ """The header's right side, plain: `◆1 ◇2 ▸6 ✓1`, zeros omitted, '' when no lane has one (spec §4.3)."""
762
+ return ' '.join(text for _status, text in self.header_tally())
763
+
764
+ # ------------------------------------------------------------------ rows
765
+ def tail_for(self, observed, econ, now=None):
766
+ """(glyph, role) for column 34, by priority (spec §4.2): not-observed beats everything, then what is
767
+ actionable — stale `?` > starving ⚠ > downgraded ↓ > paid `$` > a blank. `econ` is the lane's economy.json
768
+ object from `self.economy` or None (no poller, or a lane the poller has not joined: the tail is blank).
769
+ A FROZEN economy figure keeps its glyph but loses the attention tone (ink-dim, DEF-1447): the lane record
770
+ carries `stale: true` (the poller kept it while `/sessions` was down), or — with `now` — the whole document
771
+ is older than ECONOMY_STALE. A frozen ⚠ is last-known, not a live alarm; the `?` stays statusd's mark."""
772
+ if observed.get('stale'):
773
+ return self.glyphs['marks']['stale'], 'ink-dim'
774
+ if not econ:
775
+ return ' ', 'ink-dim'
776
+ if starving(observed, econ):
777
+ kind = 'starving'
778
+ elif (number(econ.get('downgraded_5m')) or 0) > 0:
779
+ kind = 'downgraded'
780
+ elif (number(econ.get('paid_share')) or 0) > 0.5:
781
+ kind = 'paid'
782
+ else:
783
+ return ' ', 'ink-dim'
784
+ frozen = econ.get('stale') is True or (now is not None and self.economy_aged(now))
785
+ return self.glyphs['economy'][kind], 'ink-dim' if frozen else ECONOMY_TAIL[kind][2]
786
+
787
+ def row(self, entry, index, target, others, cols, now, section_tone=None):
788
+ """One lane row (spec §4.2): columns 1-35 written, column 36 never (the caller's `\\033[K` clears it).
789
+ cursor 1 · agent 2 · mark 3 · name 4-27 · space · age 29-31 · space · stream 33 · tail 34 · × 35.
790
+ Cached per lane by everything that can change its bytes (spec §4.11); `row_hits`/`row_misses` count it."""
791
+ observed = self.status.get(entry['id']) or {}
792
+ agent = observed.get('agent') if isinstance(observed.get('agent'), dict) else {}
793
+ status = agent.get('status') if agent.get('status') in AGENT_GLYPHS else None
794
+ econ = (self.economy.get('lanes') or {}).get(entry['id']) if self.economy else None
795
+ state = observed.get('state', 'helper' if entry.get('holder') == 'exec' else 'unknown')
796
+ key_state = state if state in GLYPHS else 'unknown'
797
+ stale = bool(observed.get('stale'))
798
+ since = agent.get('since') if status else observed.get('activity')
799
+ age = age3(int(now - since) if isinstance(since, (int, float)) and since > 0 else None)
800
+ is_cursor, is_viewing = index == self.cursor, entry['id'] == target
801
+ mark = ' '
802
+ for slot, name in zip(SLOTS[1:self.viewports], ('viewport-b', 'viewport-c')):
803
+ if others.get(slot) == entry['id']:
804
+ mark = self.glyphs['marks'][name]
805
+ if mark == ' ' and is_viewing and not is_cursor:
806
+ mark = self.glyphs['marks']['viewing']
807
+ # A lane statusd never observed already shows `?` as its stream glyph: one `?`, not two.
808
+ tail, tail_role = self.tail_for(observed if key_state != 'unknown' else {}, econ, now)
809
+ viewers = viewer_mark(observed).replace('·', self.glyphs['marks']['viewer'])
810
+ closable = bool(entry.get('closable'))
811
+ can_register = closable and R.eligible(entry)
812
+ key = (entry.get('name'), entry.get('color'), entry.get('icon'), closable, can_register, key_state, stale,
813
+ viewers, status, age, tail, tail_role, is_cursor, is_viewing, mark, cols, section_tone)
814
+ cached = self.row_cache.get(entry['id'])
815
+ if cached and cached[0] == key:
816
+ self.row_hits += 1
817
+ return cached[1]
818
+ self.row_misses += 1
819
+ tone, g, reset = self.tone, self.glyphs, self.tone['reset']
820
+ icon = ''
821
+ name_width = cols - 12 - len(viewers)
822
+ if self.glyph_mode == 'emoji': # spec §3: the LANE_ICONS column, 3 cells after column 3, taken from the name
823
+ icon = entry.get('icon') or L.LANE_ICONS.get('terminal', '💻')
824
+ icon += ' ' * max(0, 3 - cell_width(icon))
825
+ name_width -= 3
826
+ name_width = max(1, name_width)
827
+ name = entry.get('name', entry['id'])
828
+ clipped, used = clip(name, name_width)
829
+ if len(clipped) < len(name): # truncated: one cell less for the name, then `…` (no second width walk)
830
+ while clipped and used > name_width - 1:
831
+ used -= cell_width(clipped[-1])
832
+ clipped = clipped[:-1]
833
+ clipped, used = clipped + g['marks']['truncation'], used + 1
834
+ pad = ' ' * max(0, name_width - used)
835
+ cursor = f'{tone["accent"]}{g["marks"]["cursor"]}{reset}' if is_cursor else ' '
836
+ agent_cell = ' '
837
+ if status:
838
+ agent_role = 'ink-dim' if stale else tokens.tone_of(AGENT_GLYPHS, status)
839
+ agent_cell = f'{tone[agent_role]}{g["agent"][status]}{reset}'
840
+ mark_cell = f'{tone["accent"]}{mark}{reset}' if mark != ' ' else ' '
841
+ color = entry.get('color')
842
+ # The lane's identity colour (38;5;N) is a colour: under NO_COLOR (`none`) the name is plain ink.
843
+ fg = f'\033[38;5;{color}m' if isinstance(color, int) and self.color != 'none' else ''
844
+ lead = (tone['bold'] if is_viewing else '') + fg
845
+ name_cell = f'{lead}{clipped}{reset}' if lead else clipped
846
+ if viewers:
847
+ name_cell += f'{tone["ink-dim"]}{viewers}{reset}'
848
+ # The `+` register control (README "Daily use") has no column in spec §4.2: it takes the spacer cell 28
849
+ # (= cols − 8), inside its click box `cols − 8 ≤ x < cols − 4`, so the age keeps its place. It and the `×`
850
+ # are POSITIONED (CHA): a name whose terminal width disagrees with `cell_width` cannot push either control
851
+ # off its hit box.
852
+ spacer = f'\033[{max(1, cols - 8)}G{tone["tone-done"]}{g["marks"]["new"]}{reset}' if can_register else ' '
853
+ stream_role = 'ink-dim' if (stale or status) else tokens.tone_of(GLYPHS, key_state)
854
+ stream = f'{tone[stream_role]}{g["stream"][key_state]}{reset}'
855
+ tail_cell = f'{tone[tail_role]}{tail}{reset}' if tail != ' ' else ' '
856
+ close = f'\033[{max(1, cols - 1)}G{tone["tone-urgent"]}{g["marks"]["close"]}{reset}' if closable else ' '
857
+ age_role = section_tone or 'ink-dim'
858
+ text = (f'{cursor}{agent_cell}{mark_cell}{icon}{name_cell}{pad}{spacer}{tone[age_role]}{age}{reset} '
859
+ f'{stream}{tail_cell}{close}')
860
+ self.row_cache[entry['id']] = (key, text)
861
+ return text
862
+
863
+ def sectioned(self, lanes):
864
+ """Registry lanes in attention order (spec §4.4): Needs you (by urgency, oldest first), Working (oldest
865
+ first), Done (newest first, unseen only), then the rest in registry order under a workspace header — drawn
866
+ only when an attention section exists, so a workspace without agents looks exactly as before."""
867
+ buckets, rest = {key: [] for key, *_ in ATTENTION}, []
868
+ for entry, status, since, key in self.agent_rows(lanes):
869
+ if key is None:
870
+ rest.append(entry)
871
+ elif key == 'needs': # urgency, then the oldest `since` first; an unknown `since` sorts last
872
+ buckets[key].append(((NEEDS_RANK[status], INF if since is None else since), status, entry))
873
+ elif key == 'working':
874
+ buckets[key].append((INF if since is None else since, status, entry))
875
+ else: # done: newest first
876
+ buckets[key].append((INF if since is None else -since, status, entry))
877
+ if not any(buckets.values()):
878
+ return rest
879
+ out = []
880
+ for key, title, _glyph, _members in ATTENTION:
881
+ if buckets[key]:
882
+ bucket = sorted(buckets[key], key=itemgetter(0))
883
+ # `status`: the most urgent member's, whose glyph heads the section (`── ◆ Needs you (3)`, spec §4.4)
884
+ out.append({'id': '', 'divider': True, 'name': f'{title} ({len(bucket)})', 'section': key,
885
+ 'status': bucket[0][1]})
886
+ out += [entry for _rank, _status, entry in bucket]
887
+ if rest:
888
+ out.append({'id': '', 'divider': True, 'name': f'{self.title} ({len(rest)})', 'section': 'rest'})
889
+ return out + rest
890
+
241
891
  def visible(self):
242
- entries = list(self.entries)
892
+ self.last_visible = entries = self._visible()
893
+ return entries
894
+
895
+ # ---------------------------------------------------------------- cursor
896
+ @property
897
+ def cursor_id(self):
898
+ """The lane the cursor is on: the id of the entry `self.cursor` indexes in `last_visible` (the list it was set
899
+ against), None on a divider or past the end. Derived, never stored, so it cannot drift from the index."""
900
+ entries = self.last_visible
901
+ if 0 <= self.cursor < len(entries) and not entries[self.cursor].get('divider'):
902
+ return entries[self.cursor].get('id') or None
903
+ return None
904
+
905
+ def resolve_cursor(self, entries, cursor_id):
906
+ """The cursor is a LANE (spec §4.5). `entries` is a freshly computed list, `cursor_id` the lane the cursor was
907
+ on before it: the index is re-found from the id, and `top` shifts by the rows that appeared above it, so a lane
908
+ that moves between sections never moves the operator's eye. Then the index is clamped. render() and
909
+ render_mobile() bound the shifted `top` by the list (DEF-1433): a list that fits the body is never scrolled."""
910
+ if cursor_id:
911
+ index = next((i for i, e in enumerate(entries) if e.get('id') == cursor_id), None)
912
+ if index is not None and index != self.cursor:
913
+ self.top = max(0, self.top + (index - self.cursor))
914
+ self.cursor = index
915
+ self.cursor = max(0, min(self.cursor, len(entries) - 1))
916
+
917
+ def follow(self):
918
+ """The current list with the cursor re-resolved onto its lane: what render() and every key in handle() use.
919
+ The list can change without a reload (opening a `done` lane marks it seen; a close, a new shell)."""
920
+ cursor_id = self.cursor_id
921
+ entries = self.visible()
922
+ self.resolve_cursor(entries, cursor_id)
923
+ return entries
924
+
925
+ def set_cursor(self, index):
926
+ """Put the cursor on `index` of a list computed NOW (a filter keystroke, the registration picker): the list it
927
+ indexes is refreshed with it, so the next follow() does not drag it back to the lane it was on before."""
928
+ entries = self.visible()
929
+ self.cursor = max(0, min(index, len(entries) - 1))
930
+
931
+ def needs_you(self):
932
+ """The visible lanes whose agent needs you, in section order (approval, errored, input; oldest first)."""
933
+ out = []
934
+ for entry in self.visible():
935
+ agent = None if entry.get('divider') else (self.status.get(entry.get('id', '')) or {}).get('agent')
936
+ if isinstance(agent, dict) and agent.get('status') in NEEDS_YOU:
937
+ out.append(entry)
938
+ return out
939
+
940
+ def jump_needs_you(self, step):
941
+ """Tab / Shift-Tab: open the next / previous lane that needs you, cycling. Relative to the lane this view SHOWS
942
+ (the target file); relative to the cursor lane when the shown one is not in the list (nothing shown yet, or it
943
+ does not need you); from the first / last only when neither is — so two Tabs always visit two lanes, also
944
+ before open_lane has moved the target (spec §4.5)."""
945
+ lanes = self.needs_you()
946
+ if not lanes:
947
+ self.message = 'nothing needs you'
948
+ return
949
+ ids = [e['id'] for e in lanes]
950
+ target, cursor_id = self.current_target(), self.cursor_id
951
+ if target in ids:
952
+ index = ids.index(target)
953
+ elif cursor_id in ids:
954
+ index = ids.index(cursor_id)
955
+ else:
956
+ index = -1 if step > 0 else 0
957
+ entry = lanes[(index + step) % len(lanes)]
958
+ self.cursor = self.last_visible.index(entry) # needs_you() just computed last_visible
959
+ self.open_lane(entry)
960
+
961
+ def osc_bytes(self, target):
962
+ """The pane title when the header counts changed since the last frame, and one OSC 9 per lane per transition
963
+ into needs-you — never for `target` (the lane this view shows), never on a phone, never under LANES_NOTIFY=0
964
+ (spec §4.9). Strings only: they ride the frame's single write, after the footer."""
965
+ out = ''
966
+ counts = self.header_counts()
967
+ if counts != self.last_counts:
968
+ self.last_counts = counts
969
+ out += f'\033]2;{osc_text(self.title + (" · " + counts if counts else ""))}\033\\'
970
+ quiet = self.mobile or os.environ.get('LANES_NOTIFY') == '0'
971
+ notified, now = self.notified, {}
972
+ for entry in self.entries:
973
+ agent = (self.status.get(entry.get('id', '')) or {}).get('agent')
974
+ status = agent.get('status') if isinstance(agent, dict) else None
975
+ if status not in NEEDS_YOU:
976
+ continue
977
+ now[entry['id']] = status
978
+ if not quiet and notified.get(entry['id']) != status and entry['id'] != target:
979
+ text = osc_text(f'{entry.get("name", entry["id"])}: {agent.get("name") or status}')
980
+ out += f'\033]9;{text}\033\\'
981
+ self.notified = now # a lane that left needs-you is forgotten: its next entry is news again
982
+ return out
983
+
984
+ def _visible(self):
985
+ if self.registration:
986
+ return self.registration_choices + [{'id': '', 'name': 'Cancel', 'action': 'cancel-registration'}]
987
+ # Filter mode flattens the sections exactly as before (dividers are dropped below); the agent glyph stays.
988
+ entries = list(self.entries) if self.filter else self.sectioned(self.entries)
243
989
  if self.zmx:
244
990
  entries.append({'id': '', 'divider': True, 'name': f'zmx lanes ({len(self.zmx)})'})
245
991
  entries += self.zmx
246
992
  # Everything statusd lists as unregistered is shown, `view-…` names included: under the viewer rule
247
- # those are orphaned or operator-made sessions that are ALIVE, and nothing alive is ever hidden.
248
- entries.append({'id': '', 'divider': True, 'name': f'Unregistered ({len(self.unregistered)})'})
249
- entries.append({'id': self.workspace + '--new-shell', 'name': 'New +', 'action': 'new-shell', 'icon': '+'})
250
- entries += self.unregistered
993
+ # those are orphaned or operator-made sessions that are ALIVE, and nothing alive is ever hidden —
994
+ # except a session that IS registered (it is listed under its workspace instead, never twice).
995
+ registered = {R.key(lane) for w in self.workspaces for lane in w.get('lanes', []) if lane.get('holder') == 'tmux'}
996
+ unregistered = [e for e in self.unregistered if not R.eligible(e) or R.key(e) not in registered]
997
+ if not self.attach_only:
998
+ entries.append({'id': '', 'divider': True, 'name': f'Unregistered ({len(unregistered)})'})
999
+ entries.append({'id': self.workspace + '--new-shell', 'name': 'New +', 'action': 'new-shell', 'icon': '+'})
1000
+ entries += unregistered
251
1001
  if not self.filter:
252
1002
  return entries
253
1003
  needle = self.filter.lower()
@@ -279,6 +1029,12 @@ class Sidebar:
279
1029
  def open_lane(self, entry, slot=None):
280
1030
  if entry.get('divider'):
281
1031
  return
1032
+ if entry.get('action') == 'cancel-registration':
1033
+ self.cancel_registration()
1034
+ return
1035
+ if entry.get('action') == 'register-workspace':
1036
+ self.register_lane(entry['workspace'])
1037
+ return
282
1038
  slot = slot or self.slot
283
1039
  if slot not in SLOTS[:self.viewports]:
284
1040
  self.message = 'this view has no such viewport (open a wide view: lanes-window w2)'
@@ -287,23 +1043,14 @@ class Sidebar:
287
1043
  if entry.get('action') == 'new-shell':
288
1044
  self.new_shell(slot)
289
1045
  return
290
- target_file, pid_file = self.slot_file(slot, 'target'), self.slot_file(slot, 'pid')
1046
+ if self.agent_of(entry['id']):
1047
+ self.mark_seen(entry['id']) # the one caller (spec §10): opening a lane is looking at it
291
1048
  if self.slot_target(slot) == entry['id']:
292
1049
  self.focus_lane(slot)
293
1050
  self.filter, self.filtering, self.dirty = '', False, True
294
1051
  return
295
- os.makedirs(self.view_dir, exist_ok=True)
296
- tmp = target_file + f'.{os.getpid()}.tmp'
297
- with open(tmp, 'w') as handle:
298
- handle.write(entry['id'] + '\n')
299
- os.replace(tmp, target_file)
300
- try:
301
- with open(pid_file) as handle:
302
- pid = int(handle.read().strip())
303
- with open(f'/proc/{pid}/cmdline', 'rb') as handle:
304
- if b'view.sh' in handle.read():
305
- os.kill(pid, signal.SIGUSR1)
306
- except (OSError, ValueError):
1052
+ # The one "write the target, wake that view.sh" (lanes_lib.show_lane), shared with `lanes next|prev`.
1053
+ if not L.show_lane(self.view_dir, self.workspace, slot, entry['id']):
307
1054
  self.message = 'view not running yet'
308
1055
  self.focus_lane(slot)
309
1056
  self.filter, self.filtering = '', False
@@ -322,6 +1069,11 @@ class Sidebar:
322
1069
  self.message = str(error)
323
1070
  self.dirty = True
324
1071
 
1072
+ def request_close(self, entry):
1073
+ """`x` or a click on `×`: ask first (spec §4.6(a)); handle() closes on `y` and cancels on anything else."""
1074
+ if entry.get('closable'):
1075
+ self.confirming, self.dirty = entry, True
1076
+
325
1077
  def close_lane(self, entry):
326
1078
  if not entry.get('closable'):
327
1079
  return
@@ -354,21 +1106,26 @@ class Sidebar:
354
1106
  self.dirty = True
355
1107
  return
356
1108
  try:
357
- if entry.get('closable') or entry.get('id', '').find('--x-') >= 0:
1109
+ # Routed by the registry SHAPE, never by the id's spelling: an adopted session keeps its `--x-` id
1110
+ # but its name authority is registered-sessions.json, not user-sessions/ (DEF-1475).
1111
+ if entry.get('registered_session'):
1112
+ R.rename(entry['id'], new_name)
1113
+ self.message = f'Renamed → {new_name}'
1114
+ self.reload(force=True)
1115
+ elif entry.get('closable'):
358
1116
  # Unregistered session
359
- updated = A.rename_session(self.workspace, entry, new_name)
360
- for lst in (self.unregistered, self.zmx):
361
- for i, e in enumerate(lst):
362
- if e['id'] == entry['id']:
363
- lst[i] = dict(e, name=new_name)
1117
+ A.rename_session(self.workspace, entry, new_name)
1118
+ for i, e in enumerate(self.unregistered):
1119
+ if e['id'] == entry['id']:
1120
+ self.unregistered[i] = dict(e, name=new_name)
364
1121
  self.message = f'Renamed → {new_name}'
365
1122
  else:
366
- # Registered lane: write displayNames override to the manifest + sync
367
- source = entry.get('source')
1123
+ # Registered lane: a repositories[i].displayNames override in the board manifest + sync.
1124
+ # The lane id never changes, so the cursor, view targets and `lane open <id>` keep working.
368
1125
  registry_name = entry.get('registry_name') or entry.get('name', '')
369
- if not source:
370
- raise L.LanesError('No manifest source for this lane; edit terminals.json directly')
371
- A.rename_registered(source, registry_name, new_name)
1126
+ if not entry.get('repo'):
1127
+ raise L.LanesError('No repository for this lane; edit the workspace manifest directly')
1128
+ A.rename_registered(L.manifest_path(self.workspace), entry['repo'], registry_name, new_name)
372
1129
  # Trigger a registry rebuild
373
1130
  import subprocess as _sp
374
1131
  _sp.Popen([os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
@@ -383,8 +1140,15 @@ class Sidebar:
383
1140
  """Begin promotion confirmation for an unregistered session."""
384
1141
  if entry.get('divider') or entry.get('action'):
385
1142
  return
386
- if not entry.get('closable') and entry.get('holder') != 'tmux':
387
- self.message = 'Only tmux sessions can be promoted'
1143
+ # Gated the way `start_registration` is: only an UNREGISTERED, reconnectable tmux session. A repository
1144
+ # lane or an adopted (`registered_session`) lane is already registered; promoting it would append a
1145
+ # second declaration of the same binding to the repository's terminals.json (DEF-1474).
1146
+ if entry.get('registered_session') or entry.get('repo') or entry.get('source'):
1147
+ self.message = 'Already a registered lane; nothing to promote'
1148
+ self.dirty = True
1149
+ return
1150
+ if not entry.get('closable') or not R.eligible(entry):
1151
+ self.message = 'Only unregistered tmux sessions can be promoted'
388
1152
  self.dirty = True
389
1153
  return
390
1154
  self.promoting = True
@@ -413,6 +1177,42 @@ class Sidebar:
413
1177
  self.message = str(error)
414
1178
  self.dirty = True
415
1179
 
1180
+ def start_registration(self, entry):
1181
+ if not entry.get('closable') or not R.eligible(entry):
1182
+ return
1183
+ choices = R.destinations({'workspaces': self.workspaces}, entry)
1184
+ choices.sort(key=lambda w: w['id'] != self.workspace)
1185
+ if not choices:
1186
+ self.message, self.dirty = 'No workspace uses this session’s container', True
1187
+ return
1188
+ self.registration = entry
1189
+ self.message = ''
1190
+ self.armed = False
1191
+ self.registration_choices = [{'id': w['id'], 'workspace': w['id'], 'name': w.get('title') or w['id'],
1192
+ 'action': 'register-workspace'} for w in choices]
1193
+ self.filter, self.filtering, self.top, self.dirty = '', False, 0, True
1194
+ self.set_cursor(0)
1195
+
1196
+ def cancel_registration(self):
1197
+ self.registration, self.registration_choices = None, []
1198
+ self.top, self.dirty = 0, True
1199
+ self.set_cursor(0)
1200
+
1201
+ def register_lane(self, workspace):
1202
+ entry = self.registration
1203
+ if not entry:
1204
+ return
1205
+ try:
1206
+ lane = R.register(self.workspace, entry, workspace)
1207
+ self.unregistered = [e for e in self.unregistered if e['id'] != lane['id']]
1208
+ title = next((w.get('title') or w['id'] for w in self.workspaces if w['id'] == workspace), workspace)
1209
+ self.cancel_registration()
1210
+ self.reload(force=True)
1211
+ self.message = 'Added to ' + title
1212
+ except (L.LanesError, OSError, ValueError) as error:
1213
+ self.message = str(error)
1214
+ self.dirty = True
1215
+
416
1216
  def spools(self):
417
1217
  """Where an upload request may be spooled, best first. A suffixed view (mac-2, mac-w2) belongs to the machine
418
1218
  whose base device (mac) `lanes attach` recorded in devices/<device>.json: that machine's ONE helper serves
@@ -438,12 +1238,13 @@ class Sidebar:
438
1238
  token = re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), token[7:])
439
1239
  if token.startswith('/') and '\n' not in token:
440
1240
  paths.append(token)
1241
+ fail = self.glyphs['marks']['fail'] + ' '
441
1242
  if not paths or len(paths) != len(tokens):
442
- self.upload_note, self.upload_note_at = 'that was not a file drop', time.time()
1243
+ self.upload_note, self.upload_note_at = fail + 'that was not a file drop', time.time()
443
1244
  return
444
1245
  spool = next((s for s in self.spools() if time.time() - mtime(os.path.join(s, 'agent.alive')) / 1e9 <= 12), None)
445
1246
  if not spool: # no helper is serving either spool
446
- self.upload_note, self.upload_note_at = 'no upload helper on this device (reopen lanes-launch)', time.time()
1247
+ self.upload_note, self.upload_note_at = fail + 'no upload helper on this device (reopen lanes-launch)', time.time()
447
1248
  return
448
1249
  request = f'{int(time.time() * 1000)}-{os.getpid()}'
449
1250
  # The sidecar names the view that asked — the device whose lane gets the paste (`lanes upload --request`).
@@ -457,6 +1258,8 @@ class Sidebar:
457
1258
  self.upload = {'id': request, 'spool': spool, 'names': [os.path.basename(p) for p in paths], 'at': time.time()}
458
1259
 
459
1260
  def poll_upload(self):
1261
+ """Follow the request in flight. The strip repaints only when this changes something — a new `.progress`
1262
+ mtime or the end of the upload (spec §4.6(d)): no spinner, no fast tick."""
460
1263
  if not self.upload:
461
1264
  return
462
1265
  base = os.path.join(self.upload['spool'], self.upload['id']) # the results land where the request went
@@ -464,18 +1267,20 @@ class Sidebar:
464
1267
  if progress_at > self.upload.get('progress_at', 0):
465
1268
  progress = read_json(base + '.progress') or {}
466
1269
  self.upload.update(progress_at=progress_at, bytes=progress.get('bytes', 0))
1270
+ self.dirty = True
1271
+ ok, fail = self.glyphs['marks']['ok'] + ' ', self.glyphs['marks']['fail'] + ' '
467
1272
  note = None
468
1273
  if os.path.exists(base + '.done'):
469
- note = '✓ ' + ', '.join(self.upload['names'])[:60]
1274
+ note = ok + ', '.join(self.upload['names'])[:60]
470
1275
  self.focus_lane()
471
1276
  elif os.path.exists(base + '.err'):
472
1277
  try:
473
1278
  with open(base + '.err') as handle:
474
- note = '✖ ' + handle.readline().strip()[:70]
1279
+ note = fail + handle.readline().strip()[:70]
475
1280
  except OSError:
476
- note = '✖ upload failed'
1281
+ note = fail + 'upload failed'
477
1282
  elif time.time() - max(self.upload['at'], progress_at) > 120:
478
- note = '✖ upload timed out'
1283
+ note = fail + 'upload timed out'
479
1284
  if note:
480
1285
  for suffix in ('.done', '.err', '.sent', '.req', '.dev', '.progress'):
481
1286
  try:
@@ -495,26 +1300,70 @@ class Sidebar:
495
1300
  except (OSError, subprocess.SubprocessError):
496
1301
  break
497
1302
 
498
- def box_lines(self, cols):
499
- """The upload box: BOX_ROWS lines, last rows of the sidebar."""
500
- inner = max(4, cols - 2)
1303
+ def strip_line(self, cols):
1304
+ """The upload strip (spec §4.6(d)): ONE row under the body, at most `cols − 1` cells — idle, armed, sending,
1305
+ or the result for 8 s. Every glyph and separator is a mark, so the ASCII frame is seven-bit."""
1306
+ tone, g = self.tone, self.glyphs['marks']
1307
+ sep = f' {g["sep"]} '
501
1308
  if self.upload:
502
- spin = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[int(time.time() * 8) % 10]
503
- body, style = [f'{spin} sending {len(self.upload["names"])} file(s)…', ', '.join(self.upload['names'])], '33'
1309
+ count = len(self.upload['names'])
1310
+ text, style = f' {g["upload"]} sending {count} file{"s" if count != 1 else ""}', tone['tone-working']
504
1311
  if self.upload.get('bytes'):
505
- body[1] = f'{self.upload["bytes"] / 1048576:.1f} MiB · ' + body[1]
1312
+ text += f'{sep}{self.upload["bytes"] / 1048576:.1f} MiB'
506
1313
  elif self.armed:
507
- body, style = ['⬇ DROP FILES NOW', 'anywhere on this window · Esc'], '1;32'
1314
+ text, style = f' {g["armed"]} DROP FILES NOW{sep}anywhere{sep}Esc', tone['bold'] + tone['tone-done']
508
1315
  elif self.upload_note and time.time() - self.upload_note_at < 8:
509
- body, style = [self.upload_note, f'→ {self.workspace}/uploads'], '36'
1316
+ text = ' ' + self.upload_note
1317
+ style = tone['tone-done'] if self.upload_note.startswith(g['ok']) else tone['tone-urgent']
510
1318
  else:
511
- body, style = ['⬆ uploads — click, then drop', f'→ {self.workspace}/uploads (U)'], '2'
512
- lines = [f'\033[{style}m┌{"─" * inner}┐\033[0m']
513
- for text in body[:BOX_ROWS - 2]:
514
- text, used = clip(' ' + text, inner)
515
- lines.append(f'\033[{style}m│\033[0m\033[{style}m{text}{" " * (inner - used)}\033[0m\033[{style}m│\033[0m')
516
- lines.append(f'\033[{style}m└{"─" * inner}┘\033[0m')
517
- return lines
1319
+ text, style = f' {g["upload"]} uploads{sep}click, then drop (U)', tone['ink-dim']
1320
+ text, _used = clip(text, cols - 1)
1321
+ return f'{style}{text}{tone["reset"]}'
1322
+
1323
+ def divider_line(self, entry, cols):
1324
+ """`── ◆ Needs you (3) ───` (spec §4.4), shared by the desktop and the phone (§4.10 "section headers as
1325
+ desktop"): the rule ink-faint, the words ink-dim, the glyph of the section's most urgent member in its tone,
1326
+ filled to cols − 1 with the `rule` mark (never a literal: the ASCII frame is seven-bit). Column 36 is never
1327
+ written; the caller adds `\\033[K`."""
1328
+ tone, marks, reset = self.tone, self.glyphs['marks'], self.tone['reset']
1329
+ attention = next((s for s in ATTENTION if s[0] == entry.get('section')), None)
1330
+ rule, dim, faint = marks['rule'], tone['ink-dim'], tone['ink-faint']
1331
+ status = (entry.get('status') if entry.get('status') in AGENT_GLYPHS else attention[2]) if attention else None
1332
+ glyph = f'{self.glyphs["agent"][status]} ' if status else ''
1333
+ label, used_cols = clip(f'{glyph}{entry["name"]}', max(1, cols - 5))
1334
+ if status and label.startswith(glyph):
1335
+ painted = f'{tone[AGENT_GLYPHS[status][2]]}{glyph[:-1]}{reset}{dim} {label[len(glyph):]} {reset}'
1336
+ else:
1337
+ painted = f'{dim}{label} {reset}'
1338
+ fill = rule * max(0, cols - 1 - 4 - used_cols)
1339
+ return f'{faint}{rule * 2} {reset}{painted}{faint}{fill}{reset}'
1340
+
1341
+ def mobile_counts(self):
1342
+ """The phone header (spec §4.10): ` ◆1 need approval · ◇2 need you · ▸6 working`, from the same tally as the
1343
+ desktop header (zeros omitted, a seen `done` not counted), parts joined by the `sep` mark; '' when no lane
1344
+ has an agent status worth counting."""
1345
+ tally = self.header_tally()
1346
+ if not tally:
1347
+ return ''
1348
+ sep = f' {self.glyphs["marks"]["sep"]} '
1349
+ return ' ' + sep.join(f'{text} {MOBILE_COUNT_WORDS[status]}' for status, text in tally)
1350
+
1351
+ def key_sheet(self, cols, body):
1352
+ """`body` lines: the key sheet `?` shows (spec §4.7), ` {key:<10}{action}`, the key ink-dim; padded with
1353
+ blank lines, cut to the body. The seven-bit twins under LANES_GLYPHS=ascii."""
1354
+ index = 1 if self.glyph_mode == 'ascii' else 0
1355
+ dim, reset = self.tone['ink-dim'], self.tone['reset']
1356
+ lines = []
1357
+ for row in KEY_SHEET:
1358
+ key = row[index]
1359
+ action, _ = clip(row[2], max(0, cols - 13))
1360
+ lines.append(f'{dim} {key:<10}{reset}{action}')
1361
+ return (lines + [''] * body)[:body]
1362
+
1363
+ @staticmethod
1364
+ def tick_timeout():
1365
+ """run()'s select() timeout: 1 s, always. An upload no longer shortens it — its strip repaints on progress."""
1366
+ return 1.0
518
1367
 
519
1368
  # ------------------------------------------------------------ rendering
520
1369
  def render(self):
@@ -526,119 +1375,167 @@ class Sidebar:
526
1375
  if self.mobile:
527
1376
  self.render_mobile(cols, rows)
528
1377
  return
529
- entries = self.visible()
530
- self.box = rows >= 14
531
- body = max(1, rows - 3 - (BOX_ROWS if self.box else 0))
532
- self.box_top = 2 + body # 1-based screen row where the box starts
533
- self.cursor = max(0, min(self.cursor, len(entries) - 1))
1378
+ entries = self.follow() # the cursor re-resolved onto its lane, clamped (spec §4.5)
1379
+ if self.theme_pending and time.monotonic() >= self.theme_deadline:
1380
+ self.apply_theme(self.variant) # no OSC 11 answer within the grace: dark it is (spec §2.4)
1381
+ now = time.time()
1382
+ # Frame order (spec §4.6): header, body, upload strip (rows ≥ 14), strip row (rows ≥ 18, economy or fleet
1383
+ # attention to show), rule, footer. `box_top` is the upload strip's 1-based row; a click at or below it arms.
1384
+ # The strip row is the fleet economy line when economy.json knows a fleet fact (Task 12), else fleet attention.
1385
+ self.box = rows >= 14 and not self.registration and not self.attach_only
1386
+ economy_line = self.economy_strip(cols - 2, now)[0] if self.economy and rows >= 18 else ''
1387
+ self.strip_row = rows >= 18 and (bool(economy_line) or bool(self.fleet))
1388
+ body = max(1, rows - 3 - (BOX_ROWS if self.box else 0) - (1 if self.strip_row else 0))
1389
+ self.box_top = 2 + body
1390
+ # resolve_cursor()'s eye-preserving shift is bounded by the list: a list that ends inside the body is never
1391
+ # scrolled past its first rows (nothing above would say they are hidden; `⋮` only marks rows below).
1392
+ self.top = max(0, min(self.top, len(entries) - body))
534
1393
  if self.cursor < self.top:
535
1394
  self.top = self.cursor
536
1395
  if self.cursor >= self.top + body:
537
1396
  self.top = self.cursor - body + 1
1397
+ # The last body row says `⋮ N more` while the list continues (spec §4.7), so it never carries the cursor:
1398
+ # a cursor that would sit under it scrolls the list one row further.
1399
+ if body > 1 and self.top + body < len(entries) and self.cursor >= self.top + body - 1:
1400
+ self.top = self.cursor - body + 2
1401
+ self.more_row = 1 + body if body > 1 and self.top + body < len(entries) else 0 # 1-based screen row, 0 = none
538
1402
  target = self.current_target()
539
1403
  others = {slot: self.slot_target(slot) for slot in SLOTS[1:self.viewports]}
540
1404
  out = ['\033[?25l\033[H']
541
- counts = sum(1 for e in self.entries if self.status.get(e['id'], {}).get('state') == 'active')
542
- header, used = clip(f' {self.title}', cols - 8)
543
- out.append(f'\033[1m{header}\033[0m\033[2m{" " * max(1, cols - used - 7)}{counts:>2}/{len(self.entries):<3}\033[0m\033[K\r\n')
544
- for row in range(body):
1405
+ tone = self.tone
1406
+ marks, reset = self.glyphs['marks'], self.tone['reset']
1407
+ # Header (spec §4.3): the title, then the agent glyph counts ending at column 35 (column 36 blank), each in
1408
+ # its tone — the same glyphs as column 2, so they are the legend. No agents: `{n} lanes`, ink-dim. While the
1409
+ # health banner is up the counts are ink-dim too: a frozen status.json is not live.
1410
+ tally = self.header_tally()
1411
+ right = ' '.join(text for _status, text in tally) or f'{len(self.entries)} lanes'
1412
+ right_width = cell_width(right)
1413
+ header, used = clip(' Add to workspace' if self.registration else f' {self.title}', max(1, cols - right_width - 2))
1414
+ if tally and not self.banner:
1415
+ painted = ' '.join(f'{tone[AGENT_GLYPHS[status][2]]}{text}{reset}' for status, text in tally)
1416
+ else:
1417
+ painted = f'{tone["ink-dim"]}{right}{reset}'
1418
+ # Column 36 is never written, not even with a blank (the budget gate's cell walk, DEF-1417): `\033[K` clears it.
1419
+ out.append(f'{tone["bold"]}{header}{reset}{" " * max(1, cols - 1 - used - right_width)}{painted}\033[K\r\n')
1420
+ more = bool(self.more_row) and not self.showing_keys
1421
+ section = None
1422
+ for index in range(self.top): # the section the first visible row sits in (dividers above `top`)
1423
+ if entries[index].get('divider'):
1424
+ section = entries[index].get('section')
1425
+ g = marks
1426
+ sep, q0, q1 = f' {g["sep"]} ', g['quote-open'], g['quote-close']
1427
+ if self.showing_keys: # spec §4.7: `?` replaces the body with the key sheet until any key
1428
+ out.extend(f'{line}\033[K\r\n' for line in self.key_sheet(cols, body))
1429
+ for row in range(0 if self.showing_keys else body):
545
1430
  index = self.top + row
1431
+ if row == 0 and self.filter and not entries: # spec §4.7: filter with no hits
1432
+ label, _ = clip(f' no lane matches {q0}{self.filter}{q1}{sep}Esc', cols - 1)
1433
+ out.append(f'{tone["ink-dim"]}{label}{reset}\033[K\r\n')
1434
+ continue
546
1435
  if index >= len(entries):
547
1436
  out.append('\033[K\r\n')
548
1437
  continue
1438
+ if more and row == body - 1: # spec §4.7: the list continues — ` ⋮ N more`, the hidden rows counted
1439
+ label, _ = clip(f' {marks["more"]} {len(entries) - index} more', cols - 1)
1440
+ out.append(f'{tone["ink-dim"]}{label}{reset}\033[K\r\n')
1441
+ continue
549
1442
  entry = entries[index]
550
1443
  if entry.get('divider'):
551
- label, used_cols = clip(f'── {entry["name"]} ', cols - 1)
552
- out.append(f'\033[2m{label}{"─" * max(0, cols - 1 - used_cols)}\033[0m\033[K\r\n')
1444
+ section = entry.get('section')
1445
+ out.append(self.divider_line(entry, cols) + '\033[K\r\n')
553
1446
  continue
554
- if entry.get('action'):
555
- marker = '▌' if index == self.cursor else ' '
556
- label, _ = clip(marker + ' New +', cols - 1)
557
- out.append(f'\033[32m{label}\033[0m\033[K\r\n')
1447
+ is_cursor = index == self.cursor
1448
+ cursor = f'{tone["accent"]}{marks["cursor"]}{reset}' if is_cursor else ' '
1449
+ if entry.get('action') == 'new-shell' and not self.entries and not self.filter:
1450
+ # spec §4.7: an empty workspace says so on the New shell row itself (still the row that creates one)
1451
+ label, used_cols = clip(f' no lanes declared{sep}{g["new"]} New shell', max(1, cols - 2))
1452
+ out.append(f'{cursor}{tone["ink-dim"]}{label}{reset}{" " * max(0, cols - 2 - used_cols)}\033[K\r\n')
558
1453
  continue
559
- observed = self.status.get(entry['id'], {})
560
- state = observed.get('state', 'helper' if entry.get('holder') == 'exec' else 'unknown')
561
- glyph, glyph_color = GLYPHS.get(state, GLYPHS['unknown'])
562
- # stale = statusd got no answer about this lane and republished its LAST state: same glyph, dimmed, and a
563
- # '?' in the spare cell after it (no cell is added: rows, clicks and the upload box stay where they are).
564
- tail = ' '
565
- if observed.get('stale') and glyph != '?':
566
- glyph_color, tail = '2;' + glyph_color, '\033[2m?'
567
- is_target, is_cursor = entry['id'] == target, index == self.cursor
568
- shown = ''.join(mark for slot, mark in zip(SLOTS[1:self.viewports], '²³') if others.get(slot) == entry['id'])
569
- shown += viewer_mark(self.status.get(entry['id']))
570
- marker = '▌' if is_cursor else ' '
571
- icon = entry.get('icon') or '💻'
572
- icon_pad = ' ' * (3 - cell_width(icon))
573
- name_width = max(1, cols - (12 if entry.get('closable') else 9))
574
- # Status age suffix: show how long ago the lane was last active (quiet lanes only, compact)
575
- age_suffix = ''
576
- if state == 'quiet':
577
- activity = observed.get('activity')
578
- if activity:
579
- age_s = int(time.time() - activity)
580
- if age_s >= 60:
581
- age_suffix = f' {age_s // 60}m'
582
- elif age_s >= 10:
583
- age_suffix = f' {age_s}s'
584
- name, name_used = clip(entry.get('name', entry['id']) + shown + age_suffix, name_width)
585
- color = entry.get('color')
586
- style = ('1;' if is_target else '') + ('4;' if is_cursor else '')
587
- fg = f'38;5;{color}' if isinstance(color, int) else '39'
588
- background = '' # theme-agnostic: the cursor row is marked by ▌ + underline, not a fixed color
589
- pad = ' ' * max(0, name_width - name_used)
590
- close = f'\033[{max(1, cols - 2)}G\033[31m×\033[0m' if entry.get('closable') else ''
591
- out.append(f'{background}\033[36m{marker}\033[39m{icon}{icon_pad}\033[{style}{fg}m{name}\033[22;24;39m{pad} '
592
- f'\033[{glyph_color}m{glyph}\033[0m{background}{tail}\033[0m{close}\033[K\r\n')
593
- footer_style = '2'
594
- if self.renaming:
595
- footer = f' Rename: {self.rename_value}█'
596
- footer_style = '36'
1454
+ if entry.get('action') == 'new-shell':
1455
+ # ` + New shell` (spec §4.8): the `new` mark in column 2 (tone-done), the label ink-dim, padded to 35.
1456
+ used_cols = 4 + cell_width('New shell')
1457
+ out.append(f'{cursor}{tone["tone-done"]}{marks["new"]}{reset} {tone["ink-dim"]}New shell{reset}'
1458
+ f'{" " * max(0, cols - 1 - used_cols)}\033[K\r\n')
1459
+ continue
1460
+ if entry.get('action'): # the Add-to-workspace picker's choices and its Cancel
1461
+ label, used_cols = clip(entry['name'], max(1, cols - 4))
1462
+ out.append(f'{cursor} {tone["tone-done"]}{label}{reset}{" " * max(0, cols - 4 - used_cols)}\033[K\r\n')
1463
+ continue
1464
+ out.append(self.row(entry, index, target, others, cols, now,
1465
+ 'tone-attention' if section == 'needs' else None) + '\033[K\r\n')
1466
+ # The footer ladder (spec §4.6(a)): confirm › rename › promote › (picker) › filter › banner › registry error ›
1467
+ # message › legend. Every prompt has one shape — the `lead` mark, a sentence, `y/n` or the caret — and every
1468
+ # separator, quote, `⏎`, `›` and `⚠` is a mark lookup, so the ASCII footer is seven-bit.
1469
+ lead, caret = g['lead'], ''
1470
+ if self.confirming:
1471
+ footer, footer_style = f" {lead} close {q0}{self.confirming.get('name', '')}{q1}? y/n", tone['tone-urgent']
1472
+ elif self.renaming:
1473
+ footer, footer_style = f' {lead} Rename: {self.rename_value}', tone['ink']
1474
+ caret = f'{tone["tone-working"]}{g["caret"]}{reset}'
597
1475
  elif self.promoting:
598
- name = self.promote_entry.get('name', '') if self.promote_entry else ''
599
- footer = f' Promote {name!r} to registered? y/n'
600
- footer_style = '33'
1476
+ name = (self.promote_entry or {}).get('name', '')
1477
+ footer, footer_style = f" {lead} Register {q0}{name}{q1}? y/n", tone['tone-attention']
1478
+ elif self.registration:
1479
+ footer, footer_style = ' ' + (self.message or f'Choose a workspace{sep}Esc cancels'), tone['ink-dim']
601
1480
  elif self.filtering or self.filter:
602
- footer = f' /{self.filter}'
603
- elif self.banner: # above everything but the filter being typed: a frozen picture must never look live
604
- footer, footer_style = ' ' + self.banner, '33'
1481
+ n = sum(1 for e in entries if not e.get('divider'))
1482
+ footer, footer_style = f' /{self.filter}{sep}{n} match{"es" if n != 1 else ""}', tone['ink']
1483
+ elif self.banner: # above everything but a prompt and the filter: a frozen picture must never look live
1484
+ footer = ' ' + self.banner
1485
+ footer_style = tone['tone-urgent'] if 'sockets' in self.banner else tone['tone-attention']
605
1486
  elif self.errors:
606
- footer = ' ⚠ registry: ' + self.errors[0]
1487
+ footer, footer_style = f' {g["warning"]} registry: ' + self.errors[0], tone['tone-urgent']
607
1488
  elif self.message:
608
- footer = ' ' + self.message
1489
+ footer, footer_style = ' ' + self.message, tone['ink-dim']
1490
+ elif self.attach_only: # a remote workspace: nothing to close, no upload strip
1491
+ footer, footer_style = f' {g["enter"]} open{sep}/ find{sep}? keys', tone['ink-dim']
609
1492
  else:
610
- footer = ' ⏎ open · + new · x close · R rename · / find'
611
- footer, _ = clip(footer, cols - 1)
1493
+ footer, footer_style = f' {g["enter"]} open{sep}/ find{sep}x close{sep}? keys', tone['ink-dim']
1494
+ footer, _ = clip(footer, cols - (2 if caret else 1)) # the caret takes the last of the 35 cells
1495
+ footer += f'{reset}{caret}' if caret else ''
612
1496
  if self.box:
613
- for line in self.box_lines(cols):
614
- out.append(line + '\033[K\r\n')
615
- out.append(f'\033[2m{"─" * cols}\033[0m\r\n\033[{footer_style}m{footer}\033[0m\033[K\033[J')
1497
+ out.append(self.strip_line(cols) + '\033[K\r\n')
1498
+ if self.strip_row: # spec §4.6(c): the fleet economy line first; without one, fleet attention
1499
+ out.append((economy_line or self.fleet_attention(cols - 2)) + '\033[K\r\n')
1500
+ out.append(f'{tone["ink-faint"]}{g["rule"] * cols}{reset}\r\n{footer_style}{footer}{reset}\033[K\033[J')
1501
+ out.append(self.osc_bytes(target)) # title / notification strings: after the footer, same write (§4.9)
616
1502
  sys.stdout.write(''.join(out))
617
1503
  sys.stdout.flush()
618
1504
  self.dirty = False
619
1505
 
620
1506
  def render_mobile(self, cols, rows):
621
- """Three terminal rows per lane make the entire row a finger-sized touch target."""
622
- entries = self.visible()
1507
+ """Three terminal rows per lane make the entire row a finger-sized touch target (spec §4.10): the name with
1508
+ its agent glyph, then `label · age · harness`, then the agent's task."""
1509
+ entries = self.follow()
623
1510
  self.box = False # the Mac file-drop helper is not available in a phone browser
624
1511
  self.entry_top, self.entry_height = 3, 3 if rows >= 8 else 1
625
1512
  body = max(1, (rows - 3) // self.entry_height)
626
1513
  self.box_top = self.entry_top + body * self.entry_height
627
- self.cursor = max(0, min(self.cursor, len(entries) - 1))
628
- self.top = max(0, min(self.top, self.cursor))
1514
+ self.top = max(0, min(self.top, self.cursor, len(entries) - body)) # bounded by the list, as in render()
629
1515
  if self.cursor >= self.top + body:
630
1516
  self.top = self.cursor - body + 1
631
1517
  target = self.current_target()
1518
+ if self.theme_pending and time.monotonic() >= self.theme_deadline:
1519
+ self.apply_theme(self.variant)
632
1520
  out = ['\033[?25l\033[H']
1521
+ tone, marks = self.tone, self.glyphs['marks']
1522
+ reset = tone['reset']
633
1523
 
634
- def line(value='', style='0', closable=False):
635
- value, _ = clip(value, max(1, cols - (5 if closable else 1)))
636
- close = f'\033[{max(1, cols - 2)}G\033[31m×\033[0m' if closable else ''
637
- out.append(f'\033[{style}m{value}\033[0m{close}\033[K\r\n')
1524
+ def line(value='', style='', closable=False, can_register=False):
1525
+ value, _ = clip(value, max(1, cols - (9 if can_register else 5 if closable else 1)))
1526
+ close = f'\033[{max(1, cols - 2)}G{tone["tone-urgent"]}{marks["close"]}{reset}' if closable else ''
1527
+ add = f'\033[{max(1, cols - 6)}G{tone["tone-done"]}{marks["new"]}{reset}' if can_register else ''
1528
+ out.append(f'{reset}{style}{value}{reset}{add}{close}\033[K\r\n')
638
1529
 
639
- count = sum(not e.get('divider') and not e.get('action') for e in entries)
640
- line(f' LANES · {count}', '1;36')
1530
+ # Header (spec §4.10): the agent counts in words, bold ink — no cyan, no `LANES` banner: the phone shell's own
1531
+ # header carries identity. No agents: ` {n} lanes`, ink-dim, as on the desktop (§4.3).
1532
+ counts = '' if self.registration else self.mobile_counts()
1533
+ if self.registration:
1534
+ line(' ADD TO WORKSPACE', tone['bold'])
1535
+ else:
1536
+ line(counts or f' {len(self.entries)} lanes', tone['ink-dim'] if self.banner or not counts else tone['bold'])
641
1537
  line()
1538
+ now, sep = time.time(), f' {marks["sep"]} ' # every ` · ` is the sep mark: the ASCII frame stays seven-bit
642
1539
  for index in range(self.top, self.top + body):
643
1540
  if index >= len(entries):
644
1541
  for _ in range(self.entry_height):
@@ -647,42 +1544,85 @@ class Sidebar:
647
1544
  entry = entries[index]
648
1545
  observed = self.status.get(entry['id'], {})
649
1546
  lane_state = observed.get('state', 'helper' if entry.get('holder') == 'exec' else 'unknown')
650
- selected = entry['id'] == target
651
- marker = '›' if index == self.cursor else ' '
1547
+ agent = observed.get('agent') if isinstance(observed.get('agent'), dict) else {}
1548
+ status = agent.get('status') if agent.get('status') in AGENT_GLYPHS else None
1549
+ stale = bool(observed.get('stale'))
1550
+ if entry.get('divider'): # section headers as desktop (§4.4), three rows tall: the touch grid
1551
+ out.append(self.divider_line(entry, cols) + '\033[K\r\n')
1552
+ elif entry.get('action') == 'new-shell':
1553
+ line(f' {marks["new"]} New shell', tone['tone-done'])
1554
+ elif entry.get('action'): # the Add-to-workspace picker's choices and its Cancel
1555
+ line(f' {entry["name"]}', tone['bold'] + tone['tone-done'])
1556
+ else:
1557
+ # Row A: column 2 the viewing mark (accent), column 3 the agent glyph in its tone (ink-dim while the
1558
+ # status is stale), the name bold in its identity colour. The `›` touch cursor is gone (the cursor
1559
+ # still moves with J/K). The viewer dots are the sidebar's rule, not the desktop render's: the phone
1560
+ # is the one surface where the operator cannot also see the Studio tab (DEF-983).
1561
+ closable = bool(entry.get('closable'))
1562
+ can_register = closable and R.eligible(entry)
1563
+ mark = f'{tone["accent"]}{marks["viewing"]}{reset}' if entry['id'] == target else ' '
1564
+ glyph = ' '
1565
+ if status:
1566
+ role = 'ink-dim' if stale else tokens.tone_of(AGENT_GLYPHS, status)
1567
+ glyph = f'{tone[role]}{self.glyphs["agent"][status]}{reset}'
1568
+ viewers = viewer_mark(observed).replace('·', marks['viewer'])
1569
+ width = max(1, cols - 4 - (9 if can_register else 5 if closable else 1) - len(viewers))
1570
+ name, _ = clip(entry.get('name', entry['id']), width)
1571
+ color = entry.get('color')
1572
+ fg = f'\033[38;5;{color}m' if isinstance(color, int) and self.color != 'none' else ''
1573
+ close = f'\033[{max(1, cols - 2)}G{tone["tone-urgent"]}{marks["close"]}{reset}' if closable else ''
1574
+ add = f'\033[{max(1, cols - 6)}G{tone["tone-done"]}{marks["new"]}{reset}' if can_register else ''
1575
+ dots = f'{tone["ink-dim"]}{viewers}{reset}' if viewers else ''
1576
+ out.append(f'{reset} {mark}{glyph} {tone["bold"]}{fg}{name}{reset}{dots}{add}{close}\033[K\r\n')
1577
+ if self.entry_height == 1:
1578
+ continue
652
1579
  if entry.get('divider'):
653
- line(' ' + entry['name'], '2')
654
- elif entry.get('action'):
655
- line(f'{marker} New +', '1;32')
1580
+ line(); line()
1581
+ continue
1582
+ # Row B: what the agent needs and since when, and which harness; a shell says its state word and its
1583
+ # output age. Row C: the agent's task, blank for shells. Agent text is pane-title data: control
1584
+ # characters are dropped so a title can never inject a sequence into the frame.
1585
+ task = ''
1586
+ if entry.get('action'):
1587
+ detail = {'new-shell': 'Create a persistent shell', 'register-workspace': 'Add this session',
1588
+ 'cancel-registration': 'Keep unregistered'}.get(entry['action'], '')
1589
+ elif status and status != 'shell':
1590
+ since = agent.get('since')
1591
+ age = age3(now - since if isinstance(since, (int, float)) and since > 0 else None).strip()
1592
+ harness = OSC_UNSAFE.sub('', str(agent.get('harness') or ''))
1593
+ detail = sep.join(part for part in (MOBILE_AGENT_LABELS[status], age, harness) if part)
1594
+ task = OSC_UNSAFE.sub('', str(agent.get('name') or ''))
656
1595
  else:
657
- # The viewer dots are the sidebar's rule, not the desktop render's: the phone is the one
658
- # surface where the operator cannot also see the Studio tab, so a lane a browser views has
659
- # to say so HERE too (DEF-983).
660
- line(f'{marker} {entry.get("icon") or "▧"} {entry.get("name", entry["id"])}'
661
- + viewer_mark(observed),
662
- '1;32' if selected or entry.get('action') else '1', entry.get('closable', False))
663
- if self.entry_height > 1:
664
- label = MOBILE_LABELS.get(lane_state, lane_state)
665
- detail = ('Viewing · ' if selected else '') + label
666
- if entry.get('action'):
667
- detail = 'Create a persistent shell'
668
- if observed.get('stale'):
669
- detail += ' · status delayed'
670
- line(' ' + detail if not entry.get('divider') else '', '2')
671
- line()
1596
+ activity = observed.get('activity')
1597
+ age = age3(now - activity if isinstance(activity, (int, float)) and activity > 0 else None).strip()
1598
+ detail = sep.join(part for part in (MOBILE_LABELS.get(lane_state, lane_state), age) if part)
1599
+ if stale:
1600
+ detail += sep + 'status delayed'
1601
+ line(' ' + detail, tone['ink-dim'])
1602
+ line(' ' + task if task else '', tone['ink-dim'])
672
1603
  for _ in range(max(0, rows - (2 + body * self.entry_height + 1))):
673
1604
  line()
674
- footer = (' /' + self.filter if self.filtering or self.filter else
1605
+ q0, q1 = marks['quote-open'], marks['quote-close']
1606
+ footer = (f" {marks['lead']} close {q0}{self.confirming.get('name', '')}{q1}? y/n" if self.confirming else
1607
+ ' ' + (self.message or f'Choose a workspace{sep}Tap Cancel') if self.registration else
1608
+ ' /' + self.filter if self.filtering or self.filter else
675
1609
  ' ' + self.banner if self.banner else
676
1610
  ' ' + self.errors[0] if self.errors else
677
- ' ' + self.message if self.message else ' Swipe to scroll · Tap to open')
1611
+ ' ' + self.message if self.message else f' Swipe to scroll{sep}Tap to open')
678
1612
  footer, _ = clip(footer, max(1, cols - 1))
679
- out.append(f'\033[2m{footer}\033[0m\033[K\033[J')
1613
+ out.append(f'{tone["tone-urgent"] if self.confirming else tone["ink-dim"]}{footer}{reset}\033[K\033[J')
1614
+ out.append(self.osc_bytes(target)) # the phone reads the counts from this pane's title; no OSC 9 here
680
1615
  sys.stdout.write(''.join(out))
681
1616
  sys.stdout.flush()
682
1617
  self.dirty = False
683
1618
 
684
1619
  # ---------------------------------------------------------------- input
685
1620
  def handle(self, data):
1621
+ if isinstance(data, bytes): # the raw read, as run() gets it from the pty
1622
+ data = data.decode('utf-8', 'replace')
1623
+ # An OSC 11 answer (`\033]11;rgb:…` ST/BEL, possibly split across reads) is a theme, never an Esc: it is taken
1624
+ # out before anything reads the keys, so it cannot cancel a filter or a prompt (spec §2.4, Review Focus 6).
1625
+ data = self.strip_osc(data)
686
1626
  # Bracketed paste (a drop, or a real paste) can span several reads: buffer until its end marker.
687
1627
  if self.paste_buffer is not None:
688
1628
  self.paste_buffer += data
@@ -696,10 +1636,45 @@ class Sidebar:
696
1636
  self.dropped(pasted)
697
1637
  self.dirty = True
698
1638
  data += tail
699
- entries = self.visible()
1639
+ entries = self.follow()
700
1640
  i = 0
701
1641
  while i < len(data):
1642
+ if self.showing_keys:
1643
+ # spec §4.7: any key closes the key sheet and is consumed, never acted on. Checked per key, not per
1644
+ # read: one read can carry several keys (key repeat, fast typing), so `?j` opens and closes the sheet
1645
+ # with the j consumed, and `?xy` consumes the x — exactly one key or one whole escape sequence each.
1646
+ self.showing_keys, self.dirty = False, True
1647
+ i += key_length(data, i)
1648
+ continue
1649
+ entries = self.follow() # per key: an action may have changed the list (spec §4.5)
702
1650
  ch = data[i]
1651
+ if self.confirming is not None:
1652
+ # `y` closes the entry that asked; anything else cancels. A key is consumed by the cancel; an escape
1653
+ # sequence (Esc, an arrow, a click) cancels and is then handled as usual. Only a key or a mouse PRESS
1654
+ # answers: under ?1000h/?1006h the terminal reports the RELEASE that follows every press (the very
1655
+ # release of the `×` click that opened this prompt), and wheel/motion reports are no answer either,
1656
+ # so those are consumed and the prompt stays up.
1657
+ if ch == '\033' and data[i + 1:].startswith('[<'):
1658
+ rest = data[i + 1:]
1659
+ end = next((k for k, c in enumerate(rest) if c in 'Mm'), -1)
1660
+ if end < 0:
1661
+ break # an incomplete report is no answer
1662
+ try:
1663
+ button = int(rest[2:end].split(';')[0])
1664
+ except ValueError:
1665
+ button = -1
1666
+ if rest[end] == 'm' or (button >= 0 and button & (32 | 64)):
1667
+ i += end + 2
1668
+ continue
1669
+ entry, self.confirming, self.dirty = self.confirming, None, True
1670
+ if ch in 'yY':
1671
+ self.close_lane(entry)
1672
+ i += 1
1673
+ continue
1674
+ self.message = 'Cancelled'
1675
+ if ch != '\033':
1676
+ i += 1
1677
+ continue
703
1678
  if ch == '\033':
704
1679
  rest = data[i + 1:]
705
1680
  if rest.startswith('[<'): # SGR mouse
@@ -721,8 +1696,13 @@ class Sidebar:
721
1696
  index = self.top + (y - self.entry_top) // self.entry_height
722
1697
  if 0 <= index < len(entries) and self.entry_top <= y < self.box_top:
723
1698
  self.cursor = index
724
- if entries[index].get('closable') and _x >= max(1, self.size[0] - 4):
725
- self.close_lane(entries[index])
1699
+ if y == self.more_row and not self.mobile:
1700
+ pass # `⋮ N more` hides this entry: the click scrolls it into view, never opens it
1701
+ elif entries[index].get('closable') and _x >= max(1, self.size[0] - 4):
1702
+ self.request_close(entries[index])
1703
+ elif (entries[index].get('closable') and R.eligible(entries[index])
1704
+ and _x >= max(1, self.size[0] - 8)):
1705
+ self.start_registration(entries[index])
726
1706
  else:
727
1707
  self.open_lane(entries[index])
728
1708
  i += end + 2
@@ -736,6 +1716,10 @@ class Sidebar:
736
1716
  if entries:
737
1717
  self.open_lane(entries[self.cursor])
738
1718
  i += 3
1719
+ elif rest[:2] == '[Z': # Shift-Tab: the previous lane that needs you (a prompt keeps it)
1720
+ if not (self.renaming or self.promoting):
1721
+ self.jump_needs_you(-1)
1722
+ i += 3
739
1723
  elif rest[:2] == '[5':
740
1724
  self.cursor -= 10; i += 4
741
1725
  elif rest[:2] == '[6':
@@ -744,6 +1728,8 @@ class Sidebar:
744
1728
  end = next((k for k, c in enumerate(rest[1:], 1) if '@' <= c <= '~'), len(rest) - 1)
745
1729
  i += end + 2
746
1730
  else:
1731
+ if self.registration:
1732
+ self.cancel_registration()
747
1733
  if self.armed:
748
1734
  self.armed = False
749
1735
  self.focus_lane()
@@ -781,13 +1767,15 @@ class Sidebar:
781
1767
  self.filter = self.filter[:-1]
782
1768
  elif ch.isprintable():
783
1769
  self.filter += ch
784
- self.cursor = 0
1770
+ self.set_cursor(0) # the top match of the filtered list, not the lane the cursor was on
785
1771
  elif ch in '\r\n ':
786
1772
  if entries:
787
1773
  self.open_lane(entries[self.cursor])
788
1774
  elif ch in '23' and self.viewports > 1:
789
1775
  if entries:
790
1776
  self.open_lane(entries[self.cursor], SLOTS[int(ch) - 1])
1777
+ elif ch == '\t': # Tab: the next lane that needs you (spec §4.5)
1778
+ self.jump_needs_you(+1)
791
1779
  elif ch in 'JK': # open the next / previous lane relative to the one being shown
792
1780
  lanes = [e for e in entries if not e.get('divider')]
793
1781
  if lanes:
@@ -806,13 +1794,16 @@ class Sidebar:
806
1794
  elif ch == 'G':
807
1795
  self.cursor = len(entries) - 1
808
1796
  elif ch == '/':
809
- self.filtering, self.filter, self.cursor = True, '', 0
810
- elif ch == 'U':
1797
+ self.filtering, self.filter = True, ''
1798
+ self.set_cursor(0)
1799
+ elif ch == 'U' and not self.attach_only:
811
1800
  self.armed = True
812
- elif ch == '+':
1801
+ elif ch == '+' and not self.registration and not self.attach_only:
813
1802
  self.new_shell()
1803
+ elif ch == 'a' and entries:
1804
+ self.start_registration(entries[self.cursor])
814
1805
  elif ch == 'x' and entries:
815
- self.close_lane(entries[min(self.cursor, len(entries) - 1)])
1806
+ self.request_close(entries[min(self.cursor, len(entries) - 1)])
816
1807
  elif ch == 'R' and entries:
817
1808
  entry = entries[min(self.cursor, len(entries) - 1)]
818
1809
  self.start_rename(entry)
@@ -821,15 +1812,26 @@ class Sidebar:
821
1812
  self.start_promote(entry)
822
1813
  elif ch == 'r':
823
1814
  self.reload(force=True)
1815
+ elif ch == '?':
1816
+ self.showing_keys = True
824
1817
  self.dirty = True
825
1818
  i += 1
826
1819
 
1820
+ def start_bytes(self):
1821
+ """What run() writes once at start: alternate screen, mouse, bracketed paste — and, under LANES_THEME=auto, the
1822
+ ONE OSC 11 background query (spec §2.4: no re-ask; handle() parses the answer, the grace starts now)."""
1823
+ out = '\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[2J'
1824
+ if self.theme_pending:
1825
+ out += OSC11_QUERY
1826
+ self.theme_deadline = time.monotonic() + THEME_GRACE
1827
+ return out
1828
+
827
1829
  def run(self):
828
1830
  fd = sys.stdin.fileno()
829
1831
  saved = termios.tcgetattr(fd)
830
1832
  try:
831
1833
  tty.setraw(fd)
832
- sys.stdout.write('\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[2J')
1834
+ sys.stdout.write(self.start_bytes())
833
1835
  self.reload(force=True)
834
1836
  target = self.current_target()
835
1837
  for index, entry in enumerate(self.visible()):
@@ -847,11 +1849,13 @@ class Sidebar:
847
1849
  last_size, self.dirty = size, True
848
1850
  self.poll_upload()
849
1851
  self.poll_health()
850
- if self.upload or self.armed or (self.upload_note and time.time() - self.upload_note_at < 9):
1852
+ self.poll_economy()
1853
+ # An upload in flight no longer forces a frame: poll_upload() dirties on progress and on the end.
1854
+ if self.armed or (self.upload_note and time.time() - self.upload_note_at < 9):
851
1855
  self.dirty = True
852
1856
  if self.dirty:
853
1857
  self.render()
854
- ready, _, _ = select.select([fd], [], [], 0.15 if self.upload else 1.0)
1858
+ ready, _, _ = select.select([fd], [], [], self.tick_timeout())
855
1859
  if ready:
856
1860
  data = os.read(fd, 4096)
857
1861
  if not data: