@vibelord/lanes 1.0.0

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/lanes-install.mjs +158 -0
  4. package/lib/plan.mjs +65 -0
  5. package/package.json +37 -0
  6. package/payload/lanes/README.md +152 -0
  7. package/payload/lanes/VERSION +1 -0
  8. package/payload/lanes/bin/lanes +1035 -0
  9. package/payload/lanes/client/ghostty/common.conf +22 -0
  10. package/payload/lanes/client/ghostty/macos.conf +6 -0
  11. package/payload/lanes/client/ghostty/omarchy.conf +19 -0
  12. package/payload/lanes/client/install.sh +16 -0
  13. package/payload/lanes/client/lanes-launch +315 -0
  14. package/payload/lanes/client/lanes-upload +147 -0
  15. package/payload/lanes/client/lanes-window +36 -0
  16. package/payload/lanes/client/macos-helper.applescript +14 -0
  17. package/payload/lanes/client/omarchy/discover.sh +56 -0
  18. package/payload/lanes/client/omarchy/lanes.conf +5 -0
  19. package/payload/lanes/client/omarchy/lanes.desktop +9 -0
  20. package/payload/lanes/client/omarchy/lanes.lua +14 -0
  21. package/payload/lanes/client/ssh.stanza.in +54 -0
  22. package/payload/lanes/container/env.sh +16 -0
  23. package/payload/lanes/container/lane +268 -0
  24. package/payload/lanes/container/lane-run +34 -0
  25. package/payload/lanes/docs/ARCHITECTURE.md +93 -0
  26. package/payload/lanes/docs/HOST-GUARD.md +108 -0
  27. package/payload/lanes/docs/INSTALL.md +192 -0
  28. package/payload/lanes/docs/RUNBOOK.md +385 -0
  29. package/payload/lanes/docs/SHARING.md +124 -0
  30. package/payload/lanes/docs/ZMX-RING0.md +51 -0
  31. package/payload/lanes/docs/images/desktop.png +0 -0
  32. package/payload/lanes/docs/images/iphone.png +0 -0
  33. package/payload/lanes/holders/exec.sh +21 -0
  34. package/payload/lanes/holders/tmux.sh +111 -0
  35. package/payload/lanes/holders/zmx.sh +22 -0
  36. package/payload/lanes/holders.lock +5 -0
  37. package/payload/lanes/install/enable-phone.sh +43 -0
  38. package/payload/lanes/install/enable-web.sh +16 -0
  39. package/payload/lanes/install/host-install.sh +72 -0
  40. package/payload/lanes/install/services.py +61 -0
  41. package/payload/lanes/install.sh +603 -0
  42. package/payload/lanes/libexec/cursor_commit.py +159 -0
  43. package/payload/lanes/libexec/cursor_sync.py +166 -0
  44. package/payload/lanes/libexec/guard.py +231 -0
  45. package/payload/lanes/libexec/lanes_lib.py +610 -0
  46. package/payload/lanes/libexec/legacy_sessions.py +130 -0
  47. package/payload/lanes/libexec/session_actions.py +276 -0
  48. package/payload/lanes/libexec/sidebar +8 -0
  49. package/payload/lanes/libexec/sidebar.py +872 -0
  50. package/payload/lanes/libexec/statusd.py +764 -0
  51. package/payload/lanes/libexec/tmpscan.py +921 -0
  52. package/payload/lanes/libexec/view.sh +179 -0
  53. package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
  54. package/payload/lanes/site/README.md +28 -0
  55. package/payload/lanes/site/beyondwork/known_hosts +1 -0
  56. package/payload/lanes/site/beyondwork/site.conf +11 -0
  57. package/payload/lanes/site/site.example.conf +10 -0
  58. package/payload/lanes/sudo/host-guard.sh +356 -0
  59. package/payload/lanes/sudo/one-time-root.sh +32 -0
  60. package/payload/lanes/systemd/lanes-ensure.service +7 -0
  61. package/payload/lanes/systemd/lanes-ensure.timer +10 -0
  62. package/payload/lanes/systemd/lanes-gc.service +7 -0
  63. package/payload/lanes/systemd/lanes-gc.timer +9 -0
  64. package/payload/lanes/systemd/lanes-guard.service +8 -0
  65. package/payload/lanes/systemd/lanes-guard.timer +10 -0
  66. package/payload/lanes/systemd/lanes-mobile.service +23 -0
  67. package/payload/lanes/systemd/lanes-statusd.service +15 -0
  68. package/payload/lanes/systemd/lanes-sync.service +10 -0
  69. package/payload/lanes/systemd/lanes-sync.timer +10 -0
  70. package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
  71. package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
  72. package/payload/lanes/systemd/lanes-web.service +13 -0
  73. package/payload/lanes/systemd/lanes.slice +11 -0
  74. package/payload/lanes/web/Caddyfile +37 -0
  75. package/payload/lanes/web/README.md +35 -0
  76. package/payload/lanes/web/icon-192.png +0 -0
  77. package/payload/lanes/web/icon.svg +4 -0
  78. package/payload/lanes/web/index.html +30 -0
  79. package/payload/lanes/web/manifest.webmanifest +15 -0
  80. package/payload/lanes/web/mobile.css +301 -0
  81. package/payload/lanes/web/mobile.js +367 -0
  82. package/payload/lanes/workspaces.example/README.md +27 -0
  83. package/payload/lanes/workspaces.example/catalog.json +7 -0
  84. package/payload/lanes/workspaces.example/commit-targets.json +8 -0
  85. package/payload/lanes/workspaces.example/terminals.example.json +19 -0
  86. package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
  87. package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
  88. package/payload/lanes/zellij/config.kdl +360 -0
  89. package/payload/manifest.json +31 -0
@@ -0,0 +1,872 @@
1
+ #!/usr/bin/env python3
2
+ """Lane sidebar: a plain terminal program (no zellij plugin, no curses).
3
+
4
+ Shows one workspace's lanes with color, icon and live status. Selecting a lane writes the view's
5
+ target file and signals the viewport supervisor; the zellij layout is never touched.
6
+ Fully usable from the keyboard (touch/mouse is a bonus):
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).
12
+ Truthful under load: a lane statusd could not observe keeps its last glyph, dimmed, with a `?`; the footer line warns
13
+ when the host is under pressure or statusd has fallen behind (age of $STATE/health, measured with our own clock).
14
+ Run via libexec/sidebar (a loop), so it never leaves its pane empty.
15
+ """
16
+ import json
17
+ import os
18
+ import re
19
+ import select
20
+ import shlex
21
+ import signal
22
+ import subprocess
23
+ import sys
24
+ import termios
25
+ import time
26
+ from pathlib import Path
27
+ import tty
28
+ import unicodedata
29
+
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+ import lanes_lib as L # noqa: E402
32
+ import session_actions as A # noqa: E402
33
+
34
+ STATE = os.environ.get('LANES_STATE') or os.path.expanduser('~/.local/state/lanes')
35
+ # 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')}
39
+ # The phone sidebar spells the same six words (plus `helper`) in full. The keys are GLYPHS's keys and the
40
+ # vocabulary gate pins that: this map kept `idle` for a whole release after DEF-823 removed it from GLYPHS,
41
+ # because the gate read GLYPHS alone and the map was a literal buried inside `render_mobile` (DEF-983).
42
+ MOBILE_LABELS = {'active': 'Active', 'quiet': 'Quiet', 'missing': 'Container offline',
43
+ 'absent': 'Not started', 'dead': 'Ended', 'helper': 'Tool',
44
+ 'unknown': 'Checking status'}
45
+
46
+
47
+ def viewer_mark(status):
48
+ """The mark after a lane's name when other sessions view it: one dot per viewer, at most three."""
49
+ count = int((status or {}).get('viewers') or 0)
50
+ return '·' * min(3, count)
51
+
52
+
53
+ def read_json(path):
54
+ try:
55
+ with open(path) as handle:
56
+ return json.load(handle)
57
+ except (OSError, ValueError):
58
+ return None
59
+
60
+
61
+ def mtime(path):
62
+ try:
63
+ return os.stat(path).st_mtime_ns
64
+ except OSError:
65
+ return 0
66
+
67
+
68
+ def cell_width(text):
69
+ width = 0
70
+ for ch in text:
71
+ if unicodedata.combining(ch) or ch in '‍️':
72
+ continue
73
+ width += 2 if unicodedata.east_asian_width(ch) in ('W', 'F') else 1
74
+ return width
75
+
76
+
77
+ def clip(text, width):
78
+ out, used = [], 0
79
+ for ch in text:
80
+ w = cell_width(ch)
81
+ if used + w > width:
82
+ break
83
+ out.append(ch)
84
+ used += w
85
+ return ''.join(out), used
86
+
87
+
88
+ HEALTH_STALE = 30 # seconds; statusd rewrites $STATE/health every cycle, at most ~12 s apart when it slows itself down
89
+
90
+
91
+ def health_banner(health, written, now):
92
+ """The footer warning; '' = nothing to say. `health` = L.read_health(), `written` = the file's mtime in seconds
93
+ (0 = no such file: a statusd from before it existed — nothing is known, so nothing is claimed).
94
+ 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."""
96
+ if os.path.exists(os.path.join(STATE, 'flags', 'sockets-lost')):
97
+ # 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'
99
+ if not written:
100
+ return ''
101
+ parts, age = [], int(now - written)
102
+ if age > HEALTH_STALE:
103
+ parts.append(f'status {age // 60} m old' if age >= 60 else f'status {age // 10 * 10} s old')
104
+ level = health.get('level', 'ok')
105
+ if level != 'ok':
106
+ parts.append('host under memory pressure' if level == 'critical' else 'host busy')
107
+ return '⚠ ' + ' · '.join(parts) if parts else ''
108
+
109
+
110
+ SLOTS = ['main', 'b', 'c']
111
+ BOX_ROWS = 4
112
+ PASTE_START, PASTE_END = '\033[200~', '\033[201~'
113
+
114
+
115
+ class Sidebar:
116
+ def __init__(self, device, workspace, slot='main', viewports='1'):
117
+ 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'))
119
+ self.mobile_owner, self.mobile_titles, self.next_mobile_poll = False, {}, 0
120
+ try:
121
+ self.viewports = max(1, min(len(SLOTS), int(viewports)))
122
+ except ValueError:
123
+ self.viewports = 1
124
+ self.view_dir = os.path.join(STATE, 'views', device)
125
+ self.target_file = self.slot_file(slot, 'target')
126
+ self.pid_file = self.slot_file(slot, 'pid')
127
+ self.registry_path = os.path.join(STATE, 'registry.json')
128
+ self.status_path = os.path.join(STATE, 'status.json')
129
+ self.health_path = os.path.join(STATE, 'health')
130
+ self.health, self.health_stamp, self.banner = {}, None, ''
131
+ self.entries, self.title, self.errors = [], workspace, []
132
+ self.status, self.unregistered, self.zmx = {}, [], []
133
+ self.cursor, self.top = 0, 0
134
+ self.filter, self.filtering = '', False
135
+ self.renaming, self.rename_value, self.rename_entry = False, '', None
136
+ self.promoting, self.promote_entry = False, None
137
+ self.stamp = (0, 0, 0)
138
+ self._prev_entry_count = -1 # for scroll memory
139
+ self.size = (0, 0)
140
+ self.dirty = True
141
+ self.message = ''
142
+ self.box, self.box_top = False, 10 ** 6
143
+ self.entry_top, self.entry_height = 2, 1
144
+ self.armed, self.paste_buffer = False, None
145
+ self.upload = None # {'id', 'spool', 'names', 'at'} while a request is in flight
146
+ self.upload_note, self.upload_note_at = '', 0
147
+
148
+ def slot_file(self, slot, kind):
149
+ return os.path.join(self.view_dir, f'{self.workspace}.{slot}.{kind}')
150
+
151
+ def slot_target(self, slot):
152
+ try:
153
+ with open(self.slot_file(slot, 'target')) as handle:
154
+ return handle.readline().strip()
155
+ except OSError:
156
+ return ''
157
+
158
+ # ---------------------------------------------------------------- data
159
+ def reload(self, force=False):
160
+ stamp = (mtime(self.registry_path), mtime(self.status_path),
161
+ sum(mtime(self.slot_file(slot, 'target')) for slot in SLOTS[:self.viewports]))
162
+ if stamp == self.stamp and not force:
163
+ return
164
+ if stamp[0] != self.stamp[0] or force:
165
+ registry = read_json(self.registry_path) or {}
166
+ boards = registry.get('workspaces', [])
167
+ self.mobile_owner = self.mobile and bool(boards) and boards[0]['id'] == self.workspace
168
+ self.mobile_titles = {w.get('title') or w['id']: w['id'] for w in boards}
169
+ self.mobile_titles.update({tool['tab']: tool['view'] for tool in registry.get('tools', [])})
170
+ for workspace in registry.get('workspaces', []):
171
+ if workspace.get('id') == self.workspace:
172
+ self.title = workspace.get('title') or self.workspace
173
+ self.entries = list(workspace.get('lanes', [])) + list(workspace.get('extras', []))
174
+ self.errors = workspace.get('errors', [])
175
+ break
176
+ if stamp[1] != self.stamp[1] or force:
177
+ status = read_json(self.status_path) or {}
178
+ self.status = status.get('lanes', {})
179
+ self.unregistered = (status.get('unregistered') or {}).get(self.workspace, [])
180
+ self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
181
+ self.stamp = stamp
182
+ 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
189
+
190
+ def poll_mobile_view(self):
191
+ """One sidebar observes the phone's web client and the first display of each full-width view.
192
+
193
+ Safari bypasses `lanes attach`, which normally sets clients/<device>.ready. Hidden mobile tabs have a
194
+ plausible placeholder size, so they also need a per-workspace first-display gate before attaching tmux.
195
+ """
196
+ if not self.mobile_owner or time.monotonic() < self.next_mobile_poll:
197
+ return
198
+ self.next_mobile_poll = time.monotonic() + 1
199
+ command = [os.environ.get('LANES_ZELLIJ') or 'zellij', '-s', self.device, 'action']
200
+ ready = os.path.join(STATE, 'clients', self.device + '.ready')
201
+ if not os.path.exists(ready):
202
+ rc, out, _err = L.run_bounded(command + ['list-clients'], 2)
203
+ if rc != 0 or not any(re.match(r'^\s*\d+\s', line) for line in out.splitlines()):
204
+ return
205
+ L.atomic_write(Path(ready), f'{time.time()}\n')
206
+ rc, out, _err = L.run_bounded(command + ['list-tabs', '-j'], 2)
207
+ if rc != 0:
208
+ return
209
+ try:
210
+ title = next(tab['name'] for tab in json.loads(out) if tab.get('active'))
211
+ except (ValueError, KeyError, StopIteration, TypeError):
212
+ return
213
+ workspace = self.mobile_titles.get(title) # the "<workspace> ☰" picker tab never attaches a lane
214
+ if workspace:
215
+ shown = Path(self.view_dir) / f'{workspace}.main.shown'
216
+ if not shown.exists():
217
+ L.atomic_write(shown, f'{time.time()}\n')
218
+
219
+ def poll_health(self):
220
+ """Keep the footer banner current. Deliberately NOT part of reload()'s stamp: the health file changes every
221
+ statusd cycle and would repaint every sidebar every cycle — only a changed banner TEXT marks the screen
222
+ dirty. Polled every tick, because the case that matters is the one where no file changes at all any more."""
223
+ stamp = mtime(self.health_path)
224
+ if stamp != self.health_stamp:
225
+ self.health_stamp, self.health = stamp, L.read_health(STATE)
226
+ banner = health_banner(self.health, stamp / 1e9, time.time())
227
+ if banner != self.banner:
228
+ self.banner, self.dirty = banner, True
229
+
230
+ def current_target(self):
231
+ try:
232
+ with open(self.target_file) as handle:
233
+ return handle.readline().strip()
234
+ except OSError:
235
+ try:
236
+ with open(os.path.join(STATE, 'defaults', self.workspace)) as handle:
237
+ return handle.readline().strip()
238
+ except OSError:
239
+ return ''
240
+
241
+ def visible(self):
242
+ entries = list(self.entries)
243
+ if self.zmx:
244
+ entries.append({'id': '', 'divider': True, 'name': f'zmx lanes ({len(self.zmx)})'})
245
+ entries += self.zmx
246
+ # 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
251
+ if not self.filter:
252
+ return entries
253
+ needle = self.filter.lower()
254
+ scored = [(self._fuzzy_score(needle, e.get('name', '').lower()), e)
255
+ for e in entries if not e.get('divider')]
256
+ return [e for score, e in sorted(
257
+ ((s, e) for s, e in scored if s > 0), key=lambda t: -t[0])]
258
+
259
+ @staticmethod
260
+ def _fuzzy_score(needle, name):
261
+ """Score how well `needle` matches `name` (both lower-case). Returns 0 if no fuzzy match.
262
+ Characters of needle must appear in name in order; bonus for prefix or word-boundary hits."""
263
+ if not needle:
264
+ return 0
265
+ if needle in name:
266
+ return 1000 + (500 if name.startswith(needle) else 0)
267
+ pos, score = 0, 0
268
+ for ch in needle:
269
+ found = name.find(ch, pos)
270
+ if found < 0:
271
+ return 0
272
+ # Bonus for boundary: start of string, or after a non-alphanumeric separator
273
+ if found == 0 or not name[found - 1].isalnum():
274
+ score += 10
275
+ pos = found + 1
276
+ return max(1, score)
277
+
278
+ # -------------------------------------------------------------- actions
279
+ def open_lane(self, entry, slot=None):
280
+ if entry.get('divider'):
281
+ return
282
+ slot = slot or self.slot
283
+ if slot not in SLOTS[:self.viewports]:
284
+ self.message = 'this view has no such viewport (open a wide view: lanes-window w2)'
285
+ self.dirty = True
286
+ return
287
+ if entry.get('action') == 'new-shell':
288
+ self.new_shell(slot)
289
+ return
290
+ target_file, pid_file = self.slot_file(slot, 'target'), self.slot_file(slot, 'pid')
291
+ if self.slot_target(slot) == entry['id']:
292
+ self.focus_lane(slot)
293
+ self.filter, self.filtering, self.dirty = '', False, True
294
+ 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):
307
+ self.message = 'view not running yet'
308
+ self.focus_lane(slot)
309
+ self.filter, self.filtering = '', False
310
+ self.dirty = True
311
+
312
+ def new_shell(self, slot=None):
313
+ self.message = 'Creating shell…'
314
+ self.render()
315
+ try:
316
+ entry = A.new_shell(self.workspace)
317
+ self.unregistered.append(entry)
318
+ self.status[entry['id']] = {'state': 'quiet'}
319
+ self.open_lane(entry, slot)
320
+ self.message = 'Created ' + entry['name']
321
+ except (L.LanesError, OSError, ValueError) as error:
322
+ self.message = str(error)
323
+ self.dirty = True
324
+
325
+ def close_lane(self, entry):
326
+ if not entry.get('closable'):
327
+ return
328
+ self.message = 'Closing ' + entry['name'] + '…'
329
+ self.render()
330
+ try:
331
+ A.close_session(self.workspace, entry)
332
+ self.unregistered = [e for e in self.unregistered if e['id'] != entry['id']]
333
+ self.status.pop(entry['id'], None)
334
+ self.message = 'Closed ' + entry['name']
335
+ except (L.LanesError, OSError, ValueError) as error:
336
+ self.message = str(error)
337
+ self.dirty = True
338
+
339
+ def start_rename(self, entry):
340
+ """Begin inline rename of the lane under the cursor."""
341
+ if entry.get('divider') or entry.get('action'):
342
+ return
343
+ self.renaming = True
344
+ self.rename_entry = entry
345
+ self.rename_value = entry.get('name', '')
346
+ self.dirty = True
347
+
348
+ def commit_rename(self):
349
+ """Confirm the in-progress rename."""
350
+ entry = self.rename_entry
351
+ new_name = self.rename_value.strip()
352
+ self.renaming, self.rename_entry, self.rename_value = False, None, ''
353
+ if not new_name or not entry:
354
+ self.dirty = True
355
+ return
356
+ try:
357
+ if entry.get('closable') or entry.get('id', '').find('--x-') >= 0:
358
+ # 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)
364
+ self.message = f'Renamed → {new_name}'
365
+ else:
366
+ # Registered lane: write displayNames override to the manifest + sync
367
+ source = entry.get('source')
368
+ 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)
372
+ # Trigger a registry rebuild
373
+ import subprocess as _sp
374
+ _sp.Popen([os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
375
+ 'bin', 'lanes'), 'sync'],
376
+ stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
377
+ self.message = f'Renamed → {new_name} (sync triggered)'
378
+ except (L.LanesError, OSError, ValueError) as error:
379
+ self.message = str(error)
380
+ self.dirty = True
381
+
382
+ def start_promote(self, entry):
383
+ """Begin promotion confirmation for an unregistered session."""
384
+ if entry.get('divider') or entry.get('action'):
385
+ return
386
+ if not entry.get('closable') and entry.get('holder') != 'tmux':
387
+ self.message = 'Only tmux sessions can be promoted'
388
+ self.dirty = True
389
+ return
390
+ self.promoting = True
391
+ self.promote_entry = entry
392
+ self.dirty = True
393
+
394
+ def commit_promote(self):
395
+ """Confirm the in-progress promotion (caller has already checked 'y')."""
396
+ entry = self.promote_entry
397
+ self.promoting, self.promote_entry = False, None
398
+ if not entry:
399
+ self.dirty = True
400
+ return
401
+ try:
402
+ registry = read_json(self.registry_path) or {}
403
+ A.promote_to_registered(self.workspace, entry, registry)
404
+ # Trigger a registry rebuild so the lane moves from unregistered → registered
405
+ import subprocess as _sp
406
+ _sp.Popen([os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
407
+ 'bin', 'lanes'), 'sync'],
408
+ stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
409
+ self.unregistered = [e for e in self.unregistered if e['id'] != entry['id']]
410
+ self.message = f'Promoted — reload in a moment'
411
+ self.reload(force=True)
412
+ except (L.LanesError, OSError, ValueError) as error:
413
+ self.message = str(error)
414
+ self.dirty = True
415
+
416
+ def spools(self):
417
+ """Where an upload request may be spooled, best first. A suffixed view (mac-2, mac-w2) belongs to the machine
418
+ whose base device (mac) `lanes attach` recorded in devices/<device>.json: that machine's ONE helper serves
419
+ the base spool. The view's own spool stays as the fallback (an older client runs a helper per window).
420
+ Read per drop, not at start: the base is learnt at the first attach, and this sidebar is older than that."""
421
+ own = os.path.join(STATE, 'upload-requests', self.device)
422
+ meta = read_json(os.path.join(STATE, 'devices', self.device + '.json'))
423
+ base = meta.get('base') if isinstance(meta, dict) else None
424
+ if isinstance(base, str) and base and self.device.startswith(base + '-'): # a prefix of our own, valid name
425
+ return [os.path.join(STATE, 'upload-requests', base), own]
426
+ return [own]
427
+
428
+ def dropped(self, text):
429
+ """A drop arrives as a paste of local path(s). Ask this device's upload helper to send them."""
430
+ self.armed = False
431
+ try:
432
+ tokens = shlex.split(text.strip())
433
+ except ValueError:
434
+ tokens = text.split()
435
+ paths = []
436
+ for token in tokens:
437
+ if token.startswith('file://'):
438
+ token = re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), token[7:])
439
+ if token.startswith('/') and '\n' not in token:
440
+ paths.append(token)
441
+ if not paths or len(paths) != len(tokens):
442
+ self.upload_note, self.upload_note_at = 'that was not a file drop', time.time()
443
+ return
444
+ spool = next((s for s in self.spools() if time.time() - mtime(os.path.join(s, 'agent.alive')) / 1e9 <= 12), None)
445
+ 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()
447
+ return
448
+ request = f'{int(time.time() * 1000)}-{os.getpid()}'
449
+ # The sidecar names the view that asked — the device whose lane gets the paste (`lanes upload --request`).
450
+ # Written before the .req exists: a helper takes a .req the moment it appears.
451
+ with open(os.path.join(spool, f'{request}.dev'), 'w') as handle:
452
+ handle.write(self.device + '\n')
453
+ tmp = os.path.join(spool, f'.{request}.tmp')
454
+ with open(tmp, 'w') as handle:
455
+ handle.write('\n'.join([self.workspace, '1'] + paths) + '\n')
456
+ os.replace(tmp, os.path.join(spool, f'{request}.req'))
457
+ self.upload = {'id': request, 'spool': spool, 'names': [os.path.basename(p) for p in paths], 'at': time.time()}
458
+
459
+ def poll_upload(self):
460
+ if not self.upload:
461
+ return
462
+ base = os.path.join(self.upload['spool'], self.upload['id']) # the results land where the request went
463
+ progress_at = mtime(base + '.progress') / 1e9
464
+ if progress_at > self.upload.get('progress_at', 0):
465
+ progress = read_json(base + '.progress') or {}
466
+ self.upload.update(progress_at=progress_at, bytes=progress.get('bytes', 0))
467
+ note = None
468
+ if os.path.exists(base + '.done'):
469
+ note = '✓ ' + ', '.join(self.upload['names'])[:60]
470
+ self.focus_lane()
471
+ elif os.path.exists(base + '.err'):
472
+ try:
473
+ with open(base + '.err') as handle:
474
+ note = '✖ ' + handle.readline().strip()[:70]
475
+ except OSError:
476
+ note = '✖ upload failed'
477
+ elif time.time() - max(self.upload['at'], progress_at) > 120:
478
+ note = '✖ upload timed out'
479
+ if note:
480
+ for suffix in ('.done', '.err', '.sent', '.req', '.dev', '.progress'):
481
+ try:
482
+ os.unlink(base + suffix)
483
+ except OSError:
484
+ pass
485
+ self.upload, self.upload_note, self.upload_note_at, self.dirty = None, note, time.time(), True
486
+
487
+ def focus_lane(self, slot=None):
488
+ if os.environ.get('ZELLIJ'):
489
+ actions = ([['go-to-tab-name', self.title]] if self.mobile else
490
+ [['move-focus', 'right']] * (SLOTS.index(slot or self.slot) + 1))
491
+ for action in actions:
492
+ try:
493
+ subprocess.run([os.environ.get('LANES_ZELLIJ') or 'zellij', 'action', *action], timeout=3,
494
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
495
+ except (OSError, subprocess.SubprocessError):
496
+ break
497
+
498
+ def box_lines(self, cols):
499
+ """The upload box: BOX_ROWS lines, last rows of the sidebar."""
500
+ inner = max(4, cols - 2)
501
+ 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'
504
+ if self.upload.get('bytes'):
505
+ body[1] = f'{self.upload["bytes"] / 1048576:.1f} MiB · ' + body[1]
506
+ elif self.armed:
507
+ body, style = ['⬇ DROP FILES NOW', 'anywhere on this window · Esc'], '1;32'
508
+ elif self.upload_note and time.time() - self.upload_note_at < 8:
509
+ body, style = [self.upload_note, f'→ {self.workspace}/uploads'], '36'
510
+ 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
518
+
519
+ # ------------------------------------------------------------ rendering
520
+ def render(self):
521
+ try:
522
+ cols, rows = os.get_terminal_size(sys.stdout.fileno())
523
+ except OSError:
524
+ cols, rows = 36, 24
525
+ self.size = (cols, rows)
526
+ if self.mobile:
527
+ self.render_mobile(cols, rows)
528
+ 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))
534
+ if self.cursor < self.top:
535
+ self.top = self.cursor
536
+ if self.cursor >= self.top + body:
537
+ self.top = self.cursor - body + 1
538
+ target = self.current_target()
539
+ others = {slot: self.slot_target(slot) for slot in SLOTS[1:self.viewports]}
540
+ 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):
545
+ index = self.top + row
546
+ if index >= len(entries):
547
+ out.append('\033[K\r\n')
548
+ continue
549
+ entry = entries[index]
550
+ 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')
553
+ 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')
558
+ 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'
597
+ 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'
601
+ 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'
605
+ elif self.errors:
606
+ footer = ' ⚠ registry: ' + self.errors[0]
607
+ elif self.message:
608
+ footer = ' ' + self.message
609
+ else:
610
+ footer = ' ⏎ open · + new · x close · R rename · / find'
611
+ footer, _ = clip(footer, cols - 1)
612
+ 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')
616
+ sys.stdout.write(''.join(out))
617
+ sys.stdout.flush()
618
+ self.dirty = False
619
+
620
+ 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()
623
+ self.box = False # the Mac file-drop helper is not available in a phone browser
624
+ self.entry_top, self.entry_height = 3, 3 if rows >= 8 else 1
625
+ body = max(1, (rows - 3) // self.entry_height)
626
+ 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))
629
+ if self.cursor >= self.top + body:
630
+ self.top = self.cursor - body + 1
631
+ target = self.current_target()
632
+ out = ['\033[?25l\033[H']
633
+
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')
638
+
639
+ count = sum(not e.get('divider') and not e.get('action') for e in entries)
640
+ line(f' LANES · {count}', '1;36')
641
+ line()
642
+ for index in range(self.top, self.top + body):
643
+ if index >= len(entries):
644
+ for _ in range(self.entry_height):
645
+ line()
646
+ continue
647
+ entry = entries[index]
648
+ observed = self.status.get(entry['id'], {})
649
+ 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 ' '
652
+ if entry.get('divider'):
653
+ line(' ' + entry['name'], '2')
654
+ elif entry.get('action'):
655
+ line(f'{marker} New +', '1;32')
656
+ 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()
672
+ for _ in range(max(0, rows - (2 + body * self.entry_height + 1))):
673
+ line()
674
+ footer = (' /' + self.filter if self.filtering or self.filter else
675
+ ' ' + self.banner if self.banner else
676
+ ' ' + self.errors[0] if self.errors else
677
+ ' ' + self.message if self.message else ' Swipe to scroll · Tap to open')
678
+ footer, _ = clip(footer, max(1, cols - 1))
679
+ out.append(f'\033[2m{footer}\033[0m\033[K\033[J')
680
+ sys.stdout.write(''.join(out))
681
+ sys.stdout.flush()
682
+ self.dirty = False
683
+
684
+ # ---------------------------------------------------------------- input
685
+ def handle(self, data):
686
+ # Bracketed paste (a drop, or a real paste) can span several reads: buffer until its end marker.
687
+ if self.paste_buffer is not None:
688
+ self.paste_buffer += data
689
+ data = ''
690
+ elif PASTE_START in data:
691
+ before, _, after = data.partition(PASTE_START)
692
+ self.paste_buffer, data = after, before
693
+ if self.paste_buffer is not None and PASTE_END in self.paste_buffer:
694
+ pasted, _, tail = self.paste_buffer.partition(PASTE_END)
695
+ self.paste_buffer = None
696
+ self.dropped(pasted)
697
+ self.dirty = True
698
+ data += tail
699
+ entries = self.visible()
700
+ i = 0
701
+ while i < len(data):
702
+ ch = data[i]
703
+ if ch == '\033':
704
+ rest = data[i + 1:]
705
+ if rest.startswith('[<'): # SGR mouse
706
+ end = next((k for k, c in enumerate(rest) if c in 'Mm'), -1)
707
+ if end < 0:
708
+ break
709
+ try:
710
+ button, _x, y = (int(v) for v in rest[2:end].split(';'))
711
+ except ValueError:
712
+ button, y = -1, 0
713
+ pressed = rest[end] == 'M'
714
+ if button == 64:
715
+ self.cursor -= 3
716
+ elif button == 65:
717
+ self.cursor += 3
718
+ elif button == 0 and pressed and self.box and y >= self.box_top:
719
+ self.armed = True # the click also gave this pane the focus: the next drop lands here
720
+ elif button == 0 and pressed:
721
+ index = self.top + (y - self.entry_top) // self.entry_height
722
+ if 0 <= index < len(entries) and self.entry_top <= y < self.box_top:
723
+ self.cursor = index
724
+ if entries[index].get('closable') and _x >= max(1, self.size[0] - 4):
725
+ self.close_lane(entries[index])
726
+ else:
727
+ self.open_lane(entries[index])
728
+ i += end + 2
729
+ self.dirty = True
730
+ continue
731
+ if rest[:2] in ('[A', 'OA'):
732
+ self.cursor -= 1; i += 3
733
+ elif rest[:2] in ('[B', 'OB'):
734
+ self.cursor += 1; i += 3
735
+ elif rest[:2] in ('[C', 'OC'):
736
+ if entries:
737
+ self.open_lane(entries[self.cursor])
738
+ i += 3
739
+ elif rest[:2] == '[5':
740
+ self.cursor -= 10; i += 4
741
+ elif rest[:2] == '[6':
742
+ self.cursor += 10; i += 4
743
+ elif rest[:1] == '[': # any other CSI: skip to its final byte
744
+ end = next((k for k, c in enumerate(rest[1:], 1) if '@' <= c <= '~'), len(rest) - 1)
745
+ i += end + 2
746
+ else:
747
+ if self.armed:
748
+ self.armed = False
749
+ self.focus_lane()
750
+ if self.renaming:
751
+ self.renaming, self.rename_entry, self.rename_value = False, None, ''
752
+ self.message = 'Cancelled'
753
+ elif self.promoting:
754
+ self.promoting, self.promote_entry = False, None
755
+ self.message = 'Cancelled'
756
+ self.filter, self.filtering = '', False
757
+ i += 1
758
+ self.dirty = True
759
+ continue
760
+ if self.renaming:
761
+ if ch in '\r\n':
762
+ self.commit_rename()
763
+ elif ch in '\x7f\x08':
764
+ self.rename_value = self.rename_value[:-1]
765
+ elif ch == '\x1b': # handled above; here for completeness
766
+ pass
767
+ elif ch.isprintable():
768
+ self.rename_value += ch
769
+ elif self.promoting:
770
+ if ch in 'yY':
771
+ self.commit_promote()
772
+ else:
773
+ self.promoting, self.promote_entry = False, None
774
+ self.message = 'Cancelled'
775
+ elif self.filtering:
776
+ if ch in '\r\n':
777
+ self.filtering = False
778
+ if entries:
779
+ self.open_lane(entries[min(self.cursor, len(entries) - 1)])
780
+ elif ch in '\x7f\x08':
781
+ self.filter = self.filter[:-1]
782
+ elif ch.isprintable():
783
+ self.filter += ch
784
+ self.cursor = 0
785
+ elif ch in '\r\n ':
786
+ if entries:
787
+ self.open_lane(entries[self.cursor])
788
+ elif ch in '23' and self.viewports > 1:
789
+ if entries:
790
+ self.open_lane(entries[self.cursor], SLOTS[int(ch) - 1])
791
+ elif ch in 'JK': # open the next / previous lane relative to the one being shown
792
+ lanes = [e for e in entries if not e.get('divider')]
793
+ if lanes:
794
+ ids = [e['id'] for e in lanes]
795
+ target = self.current_target()
796
+ index = ids.index(target) if target in ids else -1
797
+ entry = lanes[(index + (1 if ch == 'J' else -1)) % len(lanes)]
798
+ self.cursor = entries.index(entry)
799
+ self.open_lane(entry)
800
+ elif ch == 'j':
801
+ self.cursor += 1
802
+ elif ch == 'k':
803
+ self.cursor -= 1
804
+ elif ch == 'g':
805
+ self.cursor = 0
806
+ elif ch == 'G':
807
+ self.cursor = len(entries) - 1
808
+ elif ch == '/':
809
+ self.filtering, self.filter, self.cursor = True, '', 0
810
+ elif ch == 'U':
811
+ self.armed = True
812
+ elif ch == '+':
813
+ self.new_shell()
814
+ elif ch == 'x' and entries:
815
+ self.close_lane(entries[min(self.cursor, len(entries) - 1)])
816
+ elif ch == 'R' and entries:
817
+ entry = entries[min(self.cursor, len(entries) - 1)]
818
+ self.start_rename(entry)
819
+ elif ch == 'P' and entries:
820
+ entry = entries[min(self.cursor, len(entries) - 1)]
821
+ self.start_promote(entry)
822
+ elif ch == 'r':
823
+ self.reload(force=True)
824
+ self.dirty = True
825
+ i += 1
826
+
827
+ def run(self):
828
+ fd = sys.stdin.fileno()
829
+ saved = termios.tcgetattr(fd)
830
+ try:
831
+ tty.setraw(fd)
832
+ sys.stdout.write('\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[2J')
833
+ self.reload(force=True)
834
+ target = self.current_target()
835
+ for index, entry in enumerate(self.visible()):
836
+ if entry['id'] == target:
837
+ self.cursor = index
838
+ last_size = None
839
+ while True:
840
+ self.reload()
841
+ self.poll_mobile_view()
842
+ try:
843
+ size = tuple(os.get_terminal_size(sys.stdout.fileno()))
844
+ except OSError:
845
+ size = None
846
+ if size != last_size:
847
+ last_size, self.dirty = size, True
848
+ self.poll_upload()
849
+ self.poll_health()
850
+ if self.upload or self.armed or (self.upload_note and time.time() - self.upload_note_at < 9):
851
+ self.dirty = True
852
+ if self.dirty:
853
+ self.render()
854
+ ready, _, _ = select.select([fd], [], [], 0.15 if self.upload else 1.0)
855
+ if ready:
856
+ data = os.read(fd, 4096)
857
+ if not data:
858
+ time.sleep(1) # pty closed: let the wrapper loop decide
859
+ return
860
+ self.message = ''
861
+ self.handle(data.decode('utf-8', 'replace'))
862
+ finally:
863
+ sys.stdout.write('\033[?2004l\033[?1006l\033[?1000l\033[?1049l\033[?25h')
864
+ sys.stdout.flush()
865
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
866
+
867
+
868
+ if __name__ == '__main__':
869
+ if len(sys.argv) < 3:
870
+ sys.exit('usage: sidebar.py <device> <workspace> [slot]')
871
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
872
+ Sidebar(*sys.argv[1:5]).run()