@vibelord/lanes 1.0.0 → 1.0.1

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 (34) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +56 -1
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +281 -104
  5. package/payload/lanes/client/lanes-launch +44 -2
  6. package/payload/lanes/container/env.sh +8 -0
  7. package/payload/lanes/container/lane +19 -2
  8. package/payload/lanes/docs/ARCHITECTURE.md +86 -1
  9. package/payload/lanes/docs/DESIGN.md +72 -0
  10. package/payload/lanes/docs/INSTALL.md +39 -0
  11. package/payload/lanes/docs/RUNBOOK.md +94 -1
  12. package/payload/lanes/holders/ssh.sh +18 -0
  13. package/payload/lanes/install/enable-phone.sh +15 -6
  14. package/payload/lanes/install/enable-web.sh +19 -6
  15. package/payload/lanes/libexec/agent_state.py +214 -0
  16. package/payload/lanes/libexec/connection.py +178 -0
  17. package/payload/lanes/libexec/cursor_sync.py +139 -6
  18. package/payload/lanes/libexec/device_sync.py +134 -0
  19. package/payload/lanes/libexec/lanes_lib.py +158 -5
  20. package/payload/lanes/libexec/registrations.py +148 -0
  21. package/payload/lanes/libexec/session_actions.py +47 -15
  22. package/payload/lanes/libexec/sidebar.py +372 -61
  23. package/payload/lanes/libexec/statusd.py +126 -33
  24. package/payload/lanes/libexec/tokens.py +279 -0
  25. package/payload/lanes/libexec/tokens.sh +11 -0
  26. package/payload/lanes/libexec/view.sh +9 -8
  27. package/payload/lanes/web/README.md +15 -0
  28. package/payload/lanes/web/browser.css +22 -0
  29. package/payload/lanes/web/browser.js +73 -0
  30. package/payload/lanes/web/mobile.css +65 -33
  31. package/payload/lanes/web/mobile.js +23 -5
  32. package/payload/lanes/workspaces.example/README.md +9 -3
  33. package/payload/lanes/zellij/config.kdl +61 -3
  34. package/payload/manifest.json +6 -3
@@ -5,7 +5,9 @@ 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)
8
+ 2 / 3 open the lane under the cursor in the 2nd / 3rd viewport (wide view sessions) · x / × close (asks y/n)
9
+ Attention first: lanes whose agent needs you, is working, or finished unseen sort into sections above the rest; the
10
+ agent glyph sits in column 2, and every row carries an age (agent `since`, else last output).
9
11
  Upload box (bottom): click it or press U (Ctrl-Alt-u from anywhere), then DROP FILES anywhere on the window. A
10
12
  terminal delivers a drop as a paste of the LOCAL path into the focused pane, so the box must have focus first; the
11
13
  file itself is then sent by the upload helper on your machine (it only reads folders you allowed there).
@@ -23,6 +25,7 @@ import subprocess
23
25
  import sys
24
26
  import termios
25
27
  import time
28
+ from operator import itemgetter
26
29
  from pathlib import Path
27
30
  import tty
28
31
  import unicodedata
@@ -30,8 +33,40 @@ import unicodedata
30
33
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
34
  import lanes_lib as L # noqa: E402
32
35
  import session_actions as A # noqa: E402
36
+ import registrations as R # noqa: E402
37
+ import tokens # noqa: E402
33
38
 
34
39
  STATE = os.environ.get('LANES_STATE') or os.path.expanduser('~/.local/state/lanes')
40
+ # What the harness in a lane is doing (statusd's additive `agent{}`, Task 4/5 vocabulary): `key -> (unicode, ascii,
41
+ # tone)` from the one token source, drawn in column 2 (spec §3). A SEPARATE dict: GLYPHS' keys stay statusd's words.
42
+ AGENT_GLYPHS = tokens.AGENT_GLYPHS
43
+ # The attention queue (spec §4.4): (section key, title, the agent status whose glyph heads the section, members in
44
+ # their order of urgency). Needs you = a blocking prompt, a failure, a finished turn waiting for a prompt.
45
+ ATTENTION = (('needs', 'Needs you', 'needs_approval', ('needs_approval', 'errored', 'needs_input')),
46
+ ('working', 'Working', 'working', ('working',)),
47
+ ('done', 'Done', 'done', ('done',)))
48
+ HEADER_COUNTS = ('needs_approval', 'needs_input', 'working', 'done') # the header's glyph counts (spec §4.3)
49
+ SECTION_OF = {status: key for key, _title, _glyph, members in ATTENTION for status in members}
50
+ NEEDS_RANK = {status: rank for rank, status in enumerate(ATTENTION[0][3])}
51
+ INF = float('inf')
52
+ # Tones by role name. Until Task 22 resolves LANES_COLOR/LANES_THEME per device this is the 16-colour table (the
53
+ # user's own palette); new code paints through role names so Task 22 swaps the table, not the call sites.
54
+ TONE16 = tokens.sgr_table('16', 'dark')
55
+
56
+
57
+ def age3(seconds):
58
+ """Three cells, right-aligned: how long. None/negative = nothing known = three blanks (spec §4.2)."""
59
+ if seconds is None or seconds < 0:
60
+ return ' '
61
+ if seconds < 60:
62
+ return f'{int(seconds):2d}s'
63
+ if seconds < 3600:
64
+ return f'{int(seconds // 60):2d}m'
65
+ if seconds < 86400:
66
+ return f'{int(seconds // 3600):2d}h'
67
+ return f'{min(99, int(seconds // 86400)):2d}d'
68
+
69
+
35
70
  # The six words statusd publishes (alignment contract A3) and the helper word for an `exec` lane. There is no
36
71
  # seventh: statusd's zmx observer used to publish `idle`, which no other surface knew (DEF-823).
37
72
  GLYPHS = {'active': ('●', '32'), 'quiet': ('○', '2'), 'missing': ('✖', '31'), 'absent': ('–', '2'),
@@ -113,6 +148,18 @@ PASTE_START, PASTE_END = '\033[200~', '\033[201~'
113
148
 
114
149
 
115
150
  class Sidebar:
151
+ # Class-level defaults for the registration state, so a Sidebar built without `__init__` (the
152
+ # alignment gate constructs one with `__new__` and sets only the fields its rule needs) reads the
153
+ # 'no registration in progress' shape rather than raising; `__init__` assigns the live values.
154
+ registration, registration_choices, workspaces = None, (), ()
155
+ renaming, rename_value, rename_entry = False, '', None
156
+ promoting, promote_entry = False, None
157
+ attach_only = False
158
+ confirming = None # the entry `x` / `×` asked to close; `y` closes it, any other key cancels
159
+ seen = {} # read-only default; mark_seen() always assigns a fresh dict
160
+ tone = TONE16
161
+ last_visible = () # the list visible() last returned: the one `self.cursor` indexes
162
+
116
163
  def __init__(self, device, workspace, slot='main', viewports='1'):
117
164
  self.device, self.workspace, self.slot = device, workspace, slot
118
165
  self.mobile = bool((read_json(os.path.join(STATE, 'devices', device + '.json')) or {}).get('mobile'))
@@ -129,13 +176,22 @@ class Sidebar:
129
176
  self.health_path = os.path.join(STATE, 'health')
130
177
  self.health, self.health_stamp, self.banner = {}, None, ''
131
178
  self.entries, self.title, self.errors = [], workspace, []
179
+ self.attach_only = False
180
+ self.workspaces, self.registration, self.registration_choices = [], None, []
132
181
  self.status, self.unregistered, self.zmx = {}, [], []
133
182
  self.cursor, self.top = 0, 0
134
183
  self.filter, self.filtering = '', False
135
184
  self.renaming, self.rename_value, self.rename_entry = False, '', None
136
185
  self.promoting, self.promote_entry = False, None
186
+ self.confirming = None
187
+ self.tone = TONE16
188
+ # `done` is sticky until the lane is opened from this device: lane id -> the time it was last opened. Read
189
+ # once here (never per tick: spec §8's stat budget), written only by mark_seen().
190
+ self.seen_path = os.path.join(STATE, 'clients', device + '.seen.json')
191
+ seen = read_json(self.seen_path)
192
+ self.seen = {k: v for k, v in seen.items() if isinstance(k, str) and isinstance(v, (int, float))} \
193
+ if isinstance(seen, dict) else {}
137
194
  self.stamp = (0, 0, 0)
138
- self._prev_entry_count = -1 # for scroll memory
139
195
  self.size = (0, 0)
140
196
  self.dirty = True
141
197
  self.message = ''
@@ -161,9 +217,15 @@ class Sidebar:
161
217
  sum(mtime(self.slot_file(slot, 'target')) for slot in SLOTS[:self.viewports]))
162
218
  if stamp == self.stamp and not force:
163
219
  return
220
+ # The cursor follows the LANE, not the index (spec §4.5, §10): a lane that changes section moves rows above
221
+ # the cursor, so remember which lane it is on and re-find it after the reload. `last_visible` is the list the
222
+ # cursor indexes (render() and handle() compute it before they use the cursor) — no second sectioning pass.
223
+ before = self.last_visible
224
+ cursor_id = before[self.cursor].get('id') if 0 <= self.cursor < len(before) else ''
164
225
  if stamp[0] != self.stamp[0] or force:
165
226
  registry = read_json(self.registry_path) or {}
166
227
  boards = registry.get('workspaces', [])
228
+ self.workspaces = boards
167
229
  self.mobile_owner = self.mobile and bool(boards) and boards[0]['id'] == self.workspace
168
230
  self.mobile_titles = {w.get('title') or w['id']: w['id'] for w in boards}
169
231
  self.mobile_titles.update({tool['tab']: tool['view'] for tool in registry.get('tools', [])})
@@ -172,6 +234,7 @@ class Sidebar:
172
234
  self.title = workspace.get('title') or self.workspace
173
235
  self.entries = list(workspace.get('lanes', [])) + list(workspace.get('extras', []))
174
236
  self.errors = workspace.get('errors', [])
237
+ self.attach_only = bool(workspace.get('attach_only'))
175
238
  break
176
239
  if stamp[1] != self.stamp[1] or force:
177
240
  status = read_json(self.status_path) or {}
@@ -180,12 +243,14 @@ class Sidebar:
180
243
  self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
181
244
  self.stamp = stamp
182
245
  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
246
+ # `top` is kept (no reset when the list length changes) and shifted by the rows inserted above the cursor, so
247
+ # the cursor's lane stays on its screen row; render() still clamps both on every draw.
248
+ after = self.visible() # also refreshes last_visible for the next reload
249
+ if cursor_id:
250
+ index = next((i for i, e in enumerate(after) if e.get('id') == cursor_id), None)
251
+ if index is not None and index != self.cursor:
252
+ self.top = max(0, self.top + index - self.cursor)
253
+ self.cursor = index
189
254
 
190
255
  def poll_mobile_view(self):
191
256
  """One sidebar observes the phone's web client and the first display of each full-width view.
@@ -238,16 +303,131 @@ class Sidebar:
238
303
  except OSError:
239
304
  return ''
240
305
 
306
+ # ------------------------------------------------------------ attention
307
+ def agent_of(self, lane_id):
308
+ """The lane's `agent{}` from status.json, or {} (a plain shell, or a statusd that does not publish it)."""
309
+ agent = (self.status.get(lane_id) or {}).get('agent')
310
+ return agent if isinstance(agent, dict) else {}
311
+
312
+ def seen_done(self, lane_id):
313
+ """True when this lane's `done` is not news any more: it was opened at or after the turn finished."""
314
+ since = self.agent_of(lane_id).get('since')
315
+ return lane_id in self.seen and (since if isinstance(since, (int, float)) else 0) <= self.seen[lane_id]
316
+
317
+ def mark_seen(self, lane_id):
318
+ """Record that the operator opened this lane (only `open_lane` calls it, spec §10): a `done` turn that
319
+ finished before now leaves the Done section; a later one is news again. Persisted per device, bounded to
320
+ the lanes status.json knows, so a restarted sidebar does not resurrect what was already looked at.
321
+ Every workspace tab of a device runs its own sidebar over the SAME file, so a write merges what is on disk
322
+ (the later mark per lane wins) instead of replacing it with this process's view."""
323
+ since = self.agent_of(lane_id).get('since')
324
+ disk = read_json(self.seen_path)
325
+ seen = {k: v for k, v in (disk.items() if isinstance(disk, dict) else ())
326
+ if isinstance(k, str) and isinstance(v, (int, float))}
327
+ for key, value in self.seen.items():
328
+ seen[key] = max(value, seen.get(key, value))
329
+ seen = {k: v for k, v in seen.items() if k in self.status}
330
+ seen[lane_id] = max(time.time(), since if isinstance(since, (int, float)) else 0)
331
+ self.seen = seen
332
+ try:
333
+ L.atomic_write(Path(self.seen_path), json.dumps(seen, sort_keys=True) + '\n')
334
+ except OSError:
335
+ pass # the queue still works for this process; only a restart would forget
336
+
337
+ def agent_rows(self, lanes):
338
+ """[(entry, agent status or None, since or None, attention key or None)] in ONE pass over status.json — the
339
+ sections, the counts and the header all read this, so a render looks each lane up once (spec §4.11)."""
340
+ if not lanes:
341
+ return []
342
+ status_of, seen, out = self.status, self.seen, []
343
+ for entry in lanes:
344
+ agent = (status_of.get(entry.get('id', '')) or {}).get('agent')
345
+ status = agent.get('status') if isinstance(agent, dict) else None
346
+ if status not in AGENT_GLYPHS:
347
+ out.append((entry, None, None, None))
348
+ continue
349
+ since = agent.get('since')
350
+ since = since if isinstance(since, (int, float)) else None
351
+ key = SECTION_OF.get(status)
352
+ if key == 'done' and entry['id'] in seen and (since or 0) <= seen[entry['id']]:
353
+ key = None # opened since it finished: not news (seen_done)
354
+ out.append((entry, status, since, key))
355
+ return out
356
+
357
+ def attention(self, entry):
358
+ """The attention section key of a registry entry, or None (the remainder)."""
359
+ return self.agent_rows([entry])[0][3]
360
+
361
+ def counts(self):
362
+ """{needs, working, agents, shells} over this workspace's registry lanes: `needs` and `working` are the
363
+ sizes of those sections, `agents` the lanes running a harness (any status but `shell`), `shells` the rest."""
364
+ needs = working = agents = 0
365
+ for _entry, status, _since, key in self.agent_rows(self.entries):
366
+ needs += key == 'needs'
367
+ working += key == 'working'
368
+ agents += status is not None and status != 'shell'
369
+ return {'needs': needs, 'working': working, 'agents': agents, 'shells': len(self.entries) - agents}
370
+
371
+ def header_tally(self):
372
+ """[(agent status, 'glyph+count')] for the header, in `HEADER_COUNTS` order, zeros omitted; a seen `done`
373
+ is not counted (it left the Done section)."""
374
+ counts = {}
375
+ for _entry, status, _since, key in self.agent_rows(self.entries):
376
+ if status in HEADER_COUNTS and (status != 'done' or key):
377
+ counts[status] = counts.get(status, 0) + 1
378
+ return [(k, f'{AGENT_GLYPHS[k][0]}{counts[k]}') for k in HEADER_COUNTS if counts.get(k)]
379
+
380
+ def header_counts(self):
381
+ """The header's right side, plain: `◆1 ◇2 ▸6 ✓1`, zeros omitted, '' when no lane has one (spec §4.3)."""
382
+ return ' '.join(text for _status, text in self.header_tally())
383
+
384
+ def sectioned(self, lanes):
385
+ """Registry lanes in attention order (spec §4.4): Needs you (by urgency, oldest first), Working (oldest
386
+ first), Done (newest first, unseen only), then the rest in registry order under a workspace header — drawn
387
+ only when an attention section exists, so a workspace without agents looks exactly as before."""
388
+ buckets, rest = {key: [] for key, *_ in ATTENTION}, []
389
+ for entry, status, since, key in self.agent_rows(lanes):
390
+ if key is None:
391
+ rest.append(entry)
392
+ elif key == 'needs': # urgency, then the oldest `since` first; an unknown `since` sorts last
393
+ buckets[key].append(((NEEDS_RANK[status], INF if since is None else since), entry))
394
+ elif key == 'working':
395
+ buckets[key].append((INF if since is None else since, entry))
396
+ else: # done: newest first
397
+ buckets[key].append((INF if since is None else -since, entry))
398
+ if not any(buckets.values()):
399
+ return rest
400
+ out = []
401
+ for key, title, _glyph, _members in ATTENTION:
402
+ if buckets[key]:
403
+ bucket = sorted(buckets[key], key=itemgetter(0))
404
+ out.append({'id': '', 'divider': True, 'name': f'{title} ({len(bucket)})', 'section': key})
405
+ out += [entry for _rank, entry in bucket]
406
+ if rest:
407
+ out.append({'id': '', 'divider': True, 'name': f'{self.title} ({len(rest)})', 'section': 'rest'})
408
+ return out + rest
409
+
241
410
  def visible(self):
242
- entries = list(self.entries)
411
+ self.last_visible = entries = self._visible()
412
+ return entries
413
+
414
+ def _visible(self):
415
+ if self.registration:
416
+ return self.registration_choices + [{'id': '', 'name': 'Cancel', 'action': 'cancel-registration'}]
417
+ # Filter mode flattens the sections exactly as before (dividers are dropped below); the agent glyph stays.
418
+ entries = list(self.entries) if self.filter else self.sectioned(self.entries)
243
419
  if self.zmx:
244
420
  entries.append({'id': '', 'divider': True, 'name': f'zmx lanes ({len(self.zmx)})'})
245
421
  entries += self.zmx
246
422
  # 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
423
+ # those are orphaned or operator-made sessions that are ALIVE, and nothing alive is ever hidden —
424
+ # except a session that IS registered (it is listed under its workspace instead, never twice).
425
+ registered = {R.key(lane) for w in self.workspaces for lane in w.get('lanes', []) if lane.get('holder') == 'tmux'}
426
+ unregistered = [e for e in self.unregistered if not R.eligible(e) or R.key(e) not in registered]
427
+ if not self.attach_only:
428
+ entries.append({'id': '', 'divider': True, 'name': f'Unregistered ({len(unregistered)})'})
429
+ entries.append({'id': self.workspace + '--new-shell', 'name': 'New +', 'action': 'new-shell', 'icon': '+'})
430
+ entries += unregistered
251
431
  if not self.filter:
252
432
  return entries
253
433
  needle = self.filter.lower()
@@ -279,6 +459,12 @@ class Sidebar:
279
459
  def open_lane(self, entry, slot=None):
280
460
  if entry.get('divider'):
281
461
  return
462
+ if entry.get('action') == 'cancel-registration':
463
+ self.cancel_registration()
464
+ return
465
+ if entry.get('action') == 'register-workspace':
466
+ self.register_lane(entry['workspace'])
467
+ return
282
468
  slot = slot or self.slot
283
469
  if slot not in SLOTS[:self.viewports]:
284
470
  self.message = 'this view has no such viewport (open a wide view: lanes-window w2)'
@@ -287,6 +473,8 @@ class Sidebar:
287
473
  if entry.get('action') == 'new-shell':
288
474
  self.new_shell(slot)
289
475
  return
476
+ if self.agent_of(entry['id']):
477
+ self.mark_seen(entry['id']) # the one caller (spec §10): opening a lane is looking at it
290
478
  target_file, pid_file = self.slot_file(slot, 'target'), self.slot_file(slot, 'pid')
291
479
  if self.slot_target(slot) == entry['id']:
292
480
  self.focus_lane(slot)
@@ -322,6 +510,11 @@ class Sidebar:
322
510
  self.message = str(error)
323
511
  self.dirty = True
324
512
 
513
+ def request_close(self, entry):
514
+ """`x` or a click on `×`: ask first (spec §4.6(a)); handle() closes on `y` and cancels on anything else."""
515
+ if entry.get('closable'):
516
+ self.confirming, self.dirty = entry, True
517
+
325
518
  def close_lane(self, entry):
326
519
  if not entry.get('closable'):
327
520
  return
@@ -354,21 +547,26 @@ class Sidebar:
354
547
  self.dirty = True
355
548
  return
356
549
  try:
357
- if entry.get('closable') or entry.get('id', '').find('--x-') >= 0:
550
+ # Routed by the registry SHAPE, never by the id's spelling: an adopted session keeps its `--x-` id
551
+ # but its name authority is registered-sessions.json, not user-sessions/ (DEF-1412).
552
+ if entry.get('registered_session'):
553
+ R.rename(entry['id'], new_name)
554
+ self.message = f'Renamed → {new_name}'
555
+ self.reload(force=True)
556
+ elif entry.get('closable'):
358
557
  # 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)
558
+ A.rename_session(self.workspace, entry, new_name)
559
+ for i, e in enumerate(self.unregistered):
560
+ if e['id'] == entry['id']:
561
+ self.unregistered[i] = dict(e, name=new_name)
364
562
  self.message = f'Renamed → {new_name}'
365
563
  else:
366
- # Registered lane: write displayNames override to the manifest + sync
367
- source = entry.get('source')
564
+ # Registered lane: a repositories[i].displayNames override in the board manifest + sync.
565
+ # The lane id never changes, so the cursor, view targets and `lane open <id>` keep working.
368
566
  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)
567
+ if not entry.get('repo'):
568
+ raise L.LanesError('No repository for this lane; edit the workspace manifest directly')
569
+ A.rename_registered(L.manifest_path(self.workspace), entry['repo'], registry_name, new_name)
372
570
  # Trigger a registry rebuild
373
571
  import subprocess as _sp
374
572
  _sp.Popen([os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
@@ -383,8 +581,15 @@ class Sidebar:
383
581
  """Begin promotion confirmation for an unregistered session."""
384
582
  if entry.get('divider') or entry.get('action'):
385
583
  return
386
- if not entry.get('closable') and entry.get('holder') != 'tmux':
387
- self.message = 'Only tmux sessions can be promoted'
584
+ # Gated the way `start_registration` is: only an UNREGISTERED, reconnectable tmux session. A repository
585
+ # lane or an adopted (`registered_session`) lane is already registered; promoting it would append a
586
+ # second declaration of the same binding to the repository's terminals.json (DEF-1411).
587
+ if entry.get('registered_session') or entry.get('repo') or entry.get('source'):
588
+ self.message = 'Already a registered lane; nothing to promote'
589
+ self.dirty = True
590
+ return
591
+ if not entry.get('closable') or not R.eligible(entry):
592
+ self.message = 'Only unregistered tmux sessions can be promoted'
388
593
  self.dirty = True
389
594
  return
390
595
  self.promoting = True
@@ -413,6 +618,40 @@ class Sidebar:
413
618
  self.message = str(error)
414
619
  self.dirty = True
415
620
 
621
+ def start_registration(self, entry):
622
+ if not entry.get('closable') or not R.eligible(entry):
623
+ return
624
+ choices = R.destinations({'workspaces': self.workspaces}, entry)
625
+ choices.sort(key=lambda w: w['id'] != self.workspace)
626
+ if not choices:
627
+ self.message, self.dirty = 'No workspace uses this session’s container', True
628
+ return
629
+ self.registration = entry
630
+ self.message = ''
631
+ self.armed = False
632
+ self.registration_choices = [{'id': w['id'], 'workspace': w['id'], 'name': w.get('title') or w['id'],
633
+ 'action': 'register-workspace'} for w in choices]
634
+ self.filter, self.filtering, self.cursor, self.top, self.dirty = '', False, 0, 0, True
635
+
636
+ def cancel_registration(self):
637
+ self.registration, self.registration_choices = None, []
638
+ self.cursor, self.top, self.dirty = 0, 0, True
639
+
640
+ def register_lane(self, workspace):
641
+ entry = self.registration
642
+ if not entry:
643
+ return
644
+ try:
645
+ lane = R.register(self.workspace, entry, workspace)
646
+ self.unregistered = [e for e in self.unregistered if e['id'] != lane['id']]
647
+ title = next((w.get('title') or w['id'] for w in self.workspaces if w['id'] == workspace), workspace)
648
+ self.cancel_registration()
649
+ self.reload(force=True)
650
+ self.message = 'Added to ' + title
651
+ except (L.LanesError, OSError, ValueError) as error:
652
+ self.message = str(error)
653
+ self.dirty = True
654
+
416
655
  def spools(self):
417
656
  """Where an upload request may be spooled, best first. A suffixed view (mac-2, mac-w2) belongs to the machine
418
657
  whose base device (mac) `lanes attach` recorded in devices/<device>.json: that machine's ONE helper serves
@@ -527,7 +766,7 @@ class Sidebar:
527
766
  self.render_mobile(cols, rows)
528
767
  return
529
768
  entries = self.visible()
530
- self.box = rows >= 14
769
+ self.box = rows >= 14 and not self.registration and not self.attach_only
531
770
  body = max(1, rows - 3 - (BOX_ROWS if self.box else 0))
532
771
  self.box_top = 2 + body # 1-based screen row where the box starts
533
772
  self.cursor = max(0, min(self.cursor, len(entries) - 1))
@@ -538,9 +777,20 @@ class Sidebar:
538
777
  target = self.current_target()
539
778
  others = {slot: self.slot_target(slot) for slot in SLOTS[1:self.viewports]}
540
779
  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')
780
+ tone, now = self.tone, time.time()
781
+ # Header (spec §4.3): the title, then the agent glyph counts ending at column 35 (column 36 blank), each in
782
+ # its tone — the same glyphs as column 2, so they are the legend. No agents: `{n} lanes`, ink-dim. While the
783
+ # health banner is up the counts are ink-dim too: a frozen status.json is not live.
784
+ tally = self.header_tally()
785
+ right = ' '.join(text for _status, text in tally) or f'{len(self.entries)} lanes'
786
+ right_width = cell_width(right)
787
+ header, used = clip(' Add to workspace' if self.registration else f' {self.title}', max(1, cols - right_width - 2))
788
+ if tally and not self.banner:
789
+ painted = ' '.join(f'{tone[AGENT_GLYPHS[status][2]]}{text}{tone["reset"]}' for status, text in tally)
790
+ else:
791
+ painted = f'{tone["ink-dim"]}{right}{tone["reset"]}'
792
+ # Column 36 is never written, not even with a blank (the budget gate's cell walk, DEF-1417): `\033[K` clears it.
793
+ out.append(f'\033[1m{header}\033[0m{" " * max(1, cols - 1 - used - right_width)}{painted}\033[K\r\n')
544
794
  for row in range(body):
545
795
  index = self.top + row
546
796
  if index >= len(entries):
@@ -548,15 +798,22 @@ class Sidebar:
548
798
  continue
549
799
  entry = entries[index]
550
800
  if entry.get('divider'):
551
- label, used_cols = clip(f'── {entry["name"]} ', cols - 1)
801
+ section = next((s for s in ATTENTION if s[0] == entry.get('section')), None)
802
+ if section: # `── ◆ Needs you (3) ───`: the glyph in its tone, the words and the rule as before
803
+ glyph, _ascii, role = AGENT_GLYPHS[section[2]]
804
+ label, used_cols = clip(f'── {glyph} {entry["name"]} ', cols - 1)
805
+ label = label.replace(glyph, f'\033[0m{tone[role]}{glyph}\033[0m\033[2m', 1)
806
+ else:
807
+ label, used_cols = clip(f'── {entry["name"]} ', cols - 1)
552
808
  out.append(f'\033[2m{label}{"─" * max(0, cols - 1 - used_cols)}\033[0m\033[K\r\n')
553
809
  continue
554
810
  if entry.get('action'):
555
811
  marker = '▌' if index == self.cursor else ' '
556
- label, _ = clip(marker + ' New +', cols - 1)
812
+ label, _ = clip(marker + ' ' + entry['name'], cols - 1)
557
813
  out.append(f'\033[32m{label}\033[0m\033[K\r\n')
558
814
  continue
559
815
  observed = self.status.get(entry['id'], {})
816
+ agent = observed.get('agent') if isinstance(observed.get('agent'), dict) else {}
560
817
  state = observed.get('state', 'helper' if entry.get('holder') == 'exec' else 'unknown')
561
818
  glyph, glyph_color = GLYPHS.get(state, GLYPHS['unknown'])
562
819
  # stale = statusd got no answer about this lane and republished its LAST state: same glyph, dimmed, and a
@@ -564,40 +821,51 @@ class Sidebar:
564
821
  tail = ' '
565
822
  if observed.get('stale') and glyph != '?':
566
823
  glyph_color, tail = '2;' + glyph_color, '\033[2m?'
824
+ elif agent.get('status') in AGENT_GLYPHS:
825
+ glyph_color = '2' # the agent glyph carries the tone; the stream glyph stays readable, dimmed
826
+ # Column 2 (spec §3/§4.2): what the harness is doing, in its tone; ink-dim when stale; blank for a shell.
827
+ agent_cell = ' '
828
+ if agent.get('status') in AGENT_GLYPHS:
829
+ agent_glyph, _ascii, agent_role = AGENT_GLYPHS[agent['status']]
830
+ agent_role = 'ink-dim' if observed.get('stale') else agent_role
831
+ agent_cell = f'{tone[agent_role]}{agent_glyph}{tone["reset"]}'
567
832
  is_target, is_cursor = entry['id'] == target, index == self.cursor
568
833
  shown = ''.join(mark for slot, mark in zip(SLOTS[1:self.viewports], '²³') if others.get(slot) == entry['id'])
569
834
  shown += viewer_mark(self.status.get(entry['id']))
570
835
  marker = '▌' if is_cursor else ' '
571
836
  icon = entry.get('icon') or '💻'
572
837
  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)
838
+ can_register = entry.get('closable') and R.eligible(entry)
839
+ # 5 cells more than before go to the agent cell (1) and the age (4): the `+`/`×` columns do not move.
840
+ name_width = max(1, cols - (21 if can_register else 17 if entry.get('closable') else 14))
841
+ # Every row says how long (spec principle 7): the agent's `since` when it has one, else the last output.
842
+ since = agent.get('since') if agent.get('status') in AGENT_GLYPHS else observed.get('activity')
843
+ age = age3(int(now - since) if isinstance(since, (int, float)) and since > 0 else None)
844
+ name, name_used = clip(entry.get('name', entry['id']) + shown, name_width)
585
845
  color = entry.get('color')
586
846
  style = ('1;' if is_target else '') + ('4;' if is_cursor else '')
587
847
  fg = f'38;5;{color}' if isinstance(color, int) else '39'
588
848
  background = '' # theme-agnostic: the cursor row is marked by ▌ + underline, not a fixed color
589
849
  pad = ' ' * max(0, name_width - name_used)
590
850
  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')
851
+ add = f'\033[{max(1, cols - 6)}G\033[32m+\033[0m' if can_register else ''
852
+ age_role = 'tone-attention' if agent.get('status') in ATTENTION[0][3] else 'ink-dim'
853
+ out.append(f'{background}\033[36m{marker}\033[39m{agent_cell}{icon}{icon_pad}\033[{style}{fg}m{name}\033[22;24;39m{pad} '
854
+ f'{tone[age_role]}{age}{tone["reset"]} '
855
+ f'\033[{glyph_color}m{glyph}\033[0m{background}{tail}\033[0m{add}{close}\033[K\r\n')
593
856
  footer_style = '2'
594
- if self.renaming:
857
+ if self.confirming: # spec §4.6(a): the destructive confirm tops the footer ladder, one prompt shape
858
+ footer = f" › close ‘{self.confirming.get('name', '')}’? y/n"
859
+ footer_style = '31'
860
+ elif self.renaming:
595
861
  footer = f' Rename: {self.rename_value}█'
596
862
  footer_style = '36'
597
863
  elif self.promoting:
598
864
  name = self.promote_entry.get('name', '') if self.promote_entry else ''
599
865
  footer = f' Promote {name!r} to registered? y/n'
600
866
  footer_style = '33'
867
+ elif self.registration:
868
+ footer = ' ' + (self.message or 'Choose a workspace · Esc cancels')
601
869
  elif self.filtering or self.filter:
602
870
  footer = f' /{self.filter}'
603
871
  elif self.banner: # above everything but the filter being typed: a frozen picture must never look live
@@ -607,7 +875,7 @@ class Sidebar:
607
875
  elif self.message:
608
876
  footer = ' ' + self.message
609
877
  else:
610
- footer = ' ⏎ open · + new · x close · R rename · / find'
878
+ footer = ' Enter open · j/k select' if self.attach_only else ' ⏎ open · a add · x close · R rename · + new'
611
879
  footer, _ = clip(footer, cols - 1)
612
880
  if self.box:
613
881
  for line in self.box_lines(cols):
@@ -631,13 +899,14 @@ class Sidebar:
631
899
  target = self.current_target()
632
900
  out = ['\033[?25l\033[H']
633
901
 
634
- def line(value='', style='0', closable=False):
635
- value, _ = clip(value, max(1, cols - (5 if closable else 1)))
902
+ def line(value='', style='0', closable=False, can_register=False):
903
+ value, _ = clip(value, max(1, cols - (9 if can_register else 5 if closable else 1)))
636
904
  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')
905
+ add = f'\033[{max(1, cols - 6)}G\033[32m+\033[0m' if can_register else ''
906
+ out.append(f'\033[{style}m{value}\033[0m{add}{close}\033[K\r\n')
638
907
 
639
908
  count = sum(not e.get('divider') and not e.get('action') for e in entries)
640
- line(f' LANES · {count}', '1;36')
909
+ line(' ADD TO WORKSPACE' if self.registration else f' LANES · {count}', '1;36')
641
910
  line()
642
911
  for index in range(self.top, self.top + body):
643
912
  if index >= len(entries):
@@ -652,37 +921,43 @@ class Sidebar:
652
921
  if entry.get('divider'):
653
922
  line(' ' + entry['name'], '2')
654
923
  elif entry.get('action'):
655
- line(f'{marker} New +', '1;32')
924
+ line(f'{marker} {entry["name"]}', '1;32')
656
925
  else:
657
926
  # The viewer dots are the sidebar's rule, not the desktop render's: the phone is the one
658
927
  # surface where the operator cannot also see the Studio tab, so a lane a browser views has
659
928
  # to say so HERE too (DEF-983).
660
929
  line(f'{marker} {entry.get("icon") or "▧"} {entry.get("name", entry["id"])}'
661
930
  + viewer_mark(observed),
662
- '1;32' if selected or entry.get('action') else '1', entry.get('closable', False))
931
+ '1;32' if selected or entry.get('action') else '1', entry.get('closable', False),
932
+ entry.get('closable') and R.eligible(entry))
663
933
  if self.entry_height > 1:
664
934
  label = MOBILE_LABELS.get(lane_state, lane_state)
665
935
  detail = ('Viewing · ' if selected else '') + label
666
936
  if entry.get('action'):
667
- detail = 'Create a persistent shell'
937
+ detail = {'new-shell': 'Create a persistent shell', 'register-workspace': 'Add this session',
938
+ 'cancel-registration': 'Keep unregistered'}.get(entry['action'], '')
668
939
  if observed.get('stale'):
669
940
  detail += ' · status delayed'
670
941
  line(' ' + detail if not entry.get('divider') else '', '2')
671
942
  line()
672
943
  for _ in range(max(0, rows - (2 + body * self.entry_height + 1))):
673
944
  line()
674
- footer = (' /' + self.filter if self.filtering or self.filter else
945
+ footer = (f" › close ‘{self.confirming.get('name', '')}’? y/n" if self.confirming else
946
+ ' ' + (self.message or 'Choose a workspace · Tap Cancel') if self.registration else
947
+ ' /' + self.filter if self.filtering or self.filter else
675
948
  ' ' + self.banner if self.banner else
676
949
  ' ' + self.errors[0] if self.errors else
677
950
  ' ' + self.message if self.message else ' Swipe to scroll · Tap to open')
678
951
  footer, _ = clip(footer, max(1, cols - 1))
679
- out.append(f'\033[2m{footer}\033[0m\033[K\033[J')
952
+ out.append(f'\033[{"31" if self.confirming else "2"}m{footer}\033[0m\033[K\033[J')
680
953
  sys.stdout.write(''.join(out))
681
954
  sys.stdout.flush()
682
955
  self.dirty = False
683
956
 
684
957
  # ---------------------------------------------------------------- input
685
958
  def handle(self, data):
959
+ if isinstance(data, bytes): # the raw read, as run() gets it from the pty
960
+ data = data.decode('utf-8', 'replace')
686
961
  # Bracketed paste (a drop, or a real paste) can span several reads: buffer until its end marker.
687
962
  if self.paste_buffer is not None:
688
963
  self.paste_buffer += data
@@ -699,7 +974,36 @@ class Sidebar:
699
974
  entries = self.visible()
700
975
  i = 0
701
976
  while i < len(data):
977
+ entries = self.visible()
978
+ self.cursor = max(0, min(self.cursor, len(entries) - 1))
702
979
  ch = data[i]
980
+ if self.confirming is not None:
981
+ # `y` closes the entry that asked; anything else cancels. A key is consumed by the cancel; an escape
982
+ # sequence (Esc, an arrow, a click) cancels and is then handled as usual. Only a key or a mouse PRESS
983
+ # answers: under ?1000h/?1006h the terminal reports the RELEASE that follows every press (the very
984
+ # release of the `×` click that opened this prompt), and wheel/motion reports are no answer either,
985
+ # so those are consumed and the prompt stays up.
986
+ if ch == '\033' and data[i + 1:].startswith('[<'):
987
+ rest = data[i + 1:]
988
+ end = next((k for k, c in enumerate(rest) if c in 'Mm'), -1)
989
+ if end < 0:
990
+ break # an incomplete report is no answer
991
+ try:
992
+ button = int(rest[2:end].split(';')[0])
993
+ except ValueError:
994
+ button = -1
995
+ if rest[end] == 'm' or (button >= 0 and button & (32 | 64)):
996
+ i += end + 2
997
+ continue
998
+ entry, self.confirming, self.dirty = self.confirming, None, True
999
+ if ch in 'yY':
1000
+ self.close_lane(entry)
1001
+ i += 1
1002
+ continue
1003
+ self.message = 'Cancelled'
1004
+ if ch != '\033':
1005
+ i += 1
1006
+ continue
703
1007
  if ch == '\033':
704
1008
  rest = data[i + 1:]
705
1009
  if rest.startswith('[<'): # SGR mouse
@@ -722,7 +1026,10 @@ class Sidebar:
722
1026
  if 0 <= index < len(entries) and self.entry_top <= y < self.box_top:
723
1027
  self.cursor = index
724
1028
  if entries[index].get('closable') and _x >= max(1, self.size[0] - 4):
725
- self.close_lane(entries[index])
1029
+ self.request_close(entries[index])
1030
+ elif (entries[index].get('closable') and R.eligible(entries[index])
1031
+ and _x >= max(1, self.size[0] - 8)):
1032
+ self.start_registration(entries[index])
726
1033
  else:
727
1034
  self.open_lane(entries[index])
728
1035
  i += end + 2
@@ -744,6 +1051,8 @@ class Sidebar:
744
1051
  end = next((k for k, c in enumerate(rest[1:], 1) if '@' <= c <= '~'), len(rest) - 1)
745
1052
  i += end + 2
746
1053
  else:
1054
+ if self.registration:
1055
+ self.cancel_registration()
747
1056
  if self.armed:
748
1057
  self.armed = False
749
1058
  self.focus_lane()
@@ -807,12 +1116,14 @@ class Sidebar:
807
1116
  self.cursor = len(entries) - 1
808
1117
  elif ch == '/':
809
1118
  self.filtering, self.filter, self.cursor = True, '', 0
810
- elif ch == 'U':
1119
+ elif ch == 'U' and not self.attach_only:
811
1120
  self.armed = True
812
- elif ch == '+':
1121
+ elif ch == '+' and not self.registration and not self.attach_only:
813
1122
  self.new_shell()
1123
+ elif ch == 'a' and entries:
1124
+ self.start_registration(entries[self.cursor])
814
1125
  elif ch == 'x' and entries:
815
- self.close_lane(entries[min(self.cursor, len(entries) - 1)])
1126
+ self.request_close(entries[min(self.cursor, len(entries) - 1)])
816
1127
  elif ch == 'R' and entries:
817
1128
  entry = entries[min(self.cursor, len(entries) - 1)]
818
1129
  self.start_rename(entry)