@vibelord/lanes 1.0.1 → 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 (46) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +37 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +651 -50
  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 +18 -3
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +1 -1
  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 +310 -26
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +80 -13
  17. package/payload/lanes/docs/DESIGN.md +182 -4
  18. package/payload/lanes/docs/RUNBOOK.md +429 -39
  19. package/payload/lanes/docs/SHARING.md +1 -1
  20. package/payload/lanes/holders/exec.sh +2 -5
  21. package/payload/lanes/holders.lock +3 -0
  22. package/payload/lanes/install/host-install.sh +10 -1
  23. package/payload/lanes/install/services.py +30 -3
  24. package/payload/lanes/install.sh +108 -3
  25. package/payload/lanes/libexec/agent_state.py +32 -0
  26. package/payload/lanes/libexec/cursor_sync.py +10 -2
  27. package/payload/lanes/libexec/economy_poll.py +685 -0
  28. package/payload/lanes/libexec/guard.py +27 -1
  29. package/payload/lanes/libexec/lanes_lib.py +285 -38
  30. package/payload/lanes/libexec/notify.py +317 -0
  31. package/payload/lanes/libexec/registrations.py +1 -1
  32. package/payload/lanes/libexec/session_actions.py +7 -8
  33. package/payload/lanes/libexec/sidebar.py +911 -218
  34. package/payload/lanes/libexec/statusd.py +238 -69
  35. package/payload/lanes/libexec/tokens.py +38 -7
  36. package/payload/lanes/libexec/view.sh +13 -3
  37. package/payload/lanes/systemd/lanes-economy.service +20 -0
  38. package/payload/lanes/systemd/lanes-notify.service +19 -0
  39. package/payload/lanes/systemd/lanes-sync.service +3 -0
  40. package/payload/lanes/web/README.md +83 -4
  41. package/payload/lanes/web/index.html +2 -1
  42. package/payload/lanes/web/mobile.css +53 -198
  43. package/payload/lanes/web/mobile.js +87 -55
  44. package/payload/lanes/zellij/config.kdl +67 -16
  45. package/payload/manifest.json +4 -3
  46. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ """Opt-in push notifications: one ntfy message per lane per transition into needs-you (plan Task 14).
3
+
4
+ The desktop sidebar already raises one OSC 9 per transition while a terminal shows it (spec §4.9); this is the same
5
+ event for the operator who is away from every terminal. `lanes-notify.service` (opt-in — no installer enables it)
6
+ re-reads `STATE/status.json` when its mtime changes (checked every `--interval` seconds, 3 in the unit) and POSTs
7
+ to an ntfy server when a lane's `agent.status` ENTERS `needs_input`, `needs_approval` or `errored` — the sidebar's
8
+ NEEDS_YOU set. The six-word `state` is never read: the agent status is the only input.
9
+
10
+ * A status that stays the same is not news; a change between two needs-you statuses is (input → approval).
11
+ * The first document the service reads only seeds it: lanes that were already waiting when it (re)started are
12
+ not announced, so a restart never sends a burst.
13
+ * Presence suppression: nothing is sent while any device was at a desk within `quiet_when_device_active_s`
14
+ (default 120 s) — measured as the mtime of `STATE/clients/<device>.<client>.pid` (written only by
15
+ `lanes attach`), or of `STATE/clients/<device>.seen.json` (written by a sidebar when the operator opens a
16
+ lane) for a DESK device only: one with a `<device>.*.pid` of any age whose `STATE/devices/<device>.json` is not
17
+ `mobile: true`. Phone presence is decided by the device's mobile layout, not by how it attached: a Safari phone
18
+ has no `.pid`, and an SSH-only phone that ran `lanes attach phone --mobile` is still mobile, so opening a lane
19
+ from the phone's picker never mutes pushes (DEF-1452). A phone's own attach within the window still counts.
20
+ Typing in a lane touches neither file, so this is "attached or opened a lane at a desk recently", not "is
21
+ looking now" (DEF-1451). A suppressed transition is consumed, never sent later.
22
+ * Payload: `<lane name>: <agent task> — <status>` and, on a second line, the client command that opens the lane
23
+ (`lanes-launch lane <id>`). No client registers a URL scheme for it, so the push carries a tappable action only
24
+ when `action_url` (a template over `{lane}`) is configured.
25
+ * A failed send is one journal line (lane id and the failure class — never the topic or the bearer) and is not
26
+ retried; it never stops the service.
27
+
28
+ Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` overrides the path), `key=value` lines, `#` comments,
29
+ re-read when its mtime changes: `url` (ntfy server, http/https, required), `topic` (`[A-Za-z0-9_-]{1,64}`,
30
+ required), `token_file` (a file holding an ntfy access token, optional — sent as a bearer, never logged; an
31
+ unreadable one disables sending rather than sending unauthenticated), `quiet_when_device_active_s` (default 120;
32
+ 0 never suppresses), `action_url` (optional template, e.g. `https://lanes.example/?lane={lane}`). Without a valid
33
+ `url` and `topic` the service only tracks transitions and sends nothing.
34
+
35
+ Answering a `PermissionRequest`-held approval FROM the phone needs a request/response channel back into the hook;
36
+ that is a follow-up design, not part of this module.
37
+ """
38
+ import argparse
39
+ import glob
40
+ import json
41
+ import os
42
+ import re
43
+ import sys
44
+ import time
45
+ import urllib.error
46
+ import urllib.parse
47
+ import urllib.request
48
+ from pathlib import Path
49
+
50
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
51
+ import lanes_lib as L # noqa: E402
52
+ import tokens # noqa: E402
53
+
54
+ TRIGGERS = ('needs_input', 'needs_approval', 'errored') # the sidebar's NEEDS_YOU (sidebar.ATTENTION[0][3])
55
+ LABELS = tokens.MOBILE_AGENT_LABELS # the phone's words: Needs you / Needs approval / Error
56
+ PRIORITY = {'needs_approval': 4, 'errored': 4, 'needs_input': 3}
57
+ DEFAULT_QUIET = 120
58
+ DEFAULT_CONF = '~/.config/lanes/notify.conf'
59
+ KEYS = ('url', 'topic', 'token_file', 'quiet_when_device_active_s', 'action_url')
60
+ TOPIC = re.compile(r'^[A-Za-z0-9_-]{1,64}$')
61
+ UNSAFE = re.compile(r'[\x00-\x1f\x7f-\x9f]') # C0, DEL, C1: never inside a push line
62
+ TEXT_MAX = 200 # characters of the first payload line
63
+ TIMEOUT = 5
64
+ PRESENCE = '*.pid' # clients/ files only `lanes attach` writes
65
+ SEEN = '.seen.json' # a sidebar's opened-lane record; desk devices only
66
+ # (a .pid of any age, devices/<device>.json not mobile)
67
+
68
+
69
+ def _text(value):
70
+ return UNSAFE.sub('', str(value or ''))
71
+
72
+
73
+ def read_config(path):
74
+ """notify.conf -> {url, topic, token_file, token, quiet_when_device_active_s, action_url, enabled}. Never raises."""
75
+ raw = {}
76
+ try:
77
+ lines = Path(os.path.expanduser(str(path))).read_text().splitlines()
78
+ except (OSError, UnicodeDecodeError):
79
+ lines = []
80
+ for line in lines:
81
+ line = line.strip()
82
+ if not line or line.startswith('#') or '=' not in line:
83
+ continue
84
+ key, value = (part.strip() for part in line.split('=', 1))
85
+ if key in KEYS:
86
+ raw[key] = value
87
+ try:
88
+ quiet = max(0, int(raw.get('quiet_when_device_active_s', DEFAULT_QUIET)))
89
+ except ValueError:
90
+ quiet = DEFAULT_QUIET
91
+ url = raw.get('url', '').rstrip('/')
92
+ topic = raw.get('topic', '')
93
+ config = {'url': url, 'topic': topic, 'token_file': raw.get('token_file', ''), 'token': None,
94
+ 'quiet_when_device_active_s': quiet, 'action_url': raw.get('action_url', '')}
95
+ enabled = url.startswith(('http://', 'https://')) and bool(TOPIC.match(topic))
96
+ if enabled and config['token_file']:
97
+ try:
98
+ config['token'] = Path(os.path.expanduser(config['token_file'])).read_text().strip() or None
99
+ except (OSError, UnicodeDecodeError):
100
+ config['token'] = None
101
+ enabled = config['token'] is not None
102
+ config['enabled'] = enabled
103
+ return config
104
+
105
+
106
+ def agent_statuses(document):
107
+ """status.json -> {lane id: agent status} for lanes with an agent{} (the six-word `state` is never read)."""
108
+ lanes = document.get('lanes') if isinstance(document, dict) else None
109
+ out = {}
110
+ for lane_id, entry in (lanes.items() if isinstance(lanes, dict) else ()):
111
+ agent = entry.get('agent') if isinstance(entry, dict) else None
112
+ if isinstance(agent, dict) and isinstance(agent.get('status'), str):
113
+ out[lane_id] = agent['status']
114
+ return out
115
+
116
+
117
+ def lane_names(registry):
118
+ """registry.json -> {lane id: display name} over every workspace's lanes."""
119
+ out = {}
120
+ workspaces = registry.get('workspaces') if isinstance(registry, dict) else None
121
+ for ws in workspaces if isinstance(workspaces, list) else ():
122
+ if not isinstance(ws, dict):
123
+ continue
124
+ for lane in ws.get('lanes') or ():
125
+ if isinstance(lane, dict) and isinstance(lane.get('id'), str) and lane.get('name'):
126
+ out[lane['id']] = str(lane['name'])
127
+ return out
128
+
129
+
130
+ def is_mobile(devices_dir, device):
131
+ """True when `STATE/devices/<device>.json` says `mobile: true` — the phone layout `lanes prepare|attach
132
+ --mobile` created, the same mark the device's sidebar reads. Missing or unreadable meta is not mobile."""
133
+ meta = L.read_json(Path(devices_dir) / f'{device}.json')
134
+ return isinstance(meta, dict) and bool(meta.get('mobile'))
135
+
136
+
137
+ def device_active(clients_dir, window, now, devices_dir=None):
138
+ """True when a device attached (`<device>.<client>.pid`) or a desk device opened a lane (`<device>.seen.json`)
139
+ within `window` seconds of `now`. `seen.json` counts only for a device that has a `<device>.*.pid` of any age
140
+ (the mark of `lanes attach`) AND whose `devices_dir/<device>.json` is not `mobile: true`. The mobile flag is
141
+ the authoritative phone mark: a Safari phone never runs `lanes attach`, but an SSH-only phone may
142
+ (`lanes attach phone --mobile`, RUNBOOK) and its pid is never removed — so a lane opened from the phone's picker
143
+ never mutes the phone's own pushes, however it attached (DEF-1452). `devices_dir` defaults to the `devices`
144
+ directory beside `clients_dir` (both live in STATE). A missing directory is nobody present; a window of 0 is
145
+ never present."""
146
+ if window <= 0:
147
+ return False
148
+ devices_dir = Path(clients_dir).parent / 'devices' if devices_dir is None else Path(devices_dir)
149
+
150
+ def recent(path):
151
+ try:
152
+ return now - path.stat().st_mtime <= window
153
+ except OSError:
154
+ return False
155
+
156
+ try:
157
+ directory = Path(clients_dir)
158
+ if any(recent(path) for path in directory.glob(PRESENCE)):
159
+ return True
160
+ for path in directory.glob('*' + SEEN):
161
+ device = path.name[:-len(SEEN)]
162
+ if (device and recent(path) and any(directory.glob(glob.escape(device) + '.' + PRESENCE))
163
+ and not is_mobile(devices_dir, device)):
164
+ return True
165
+ except OSError:
166
+ return False
167
+ return False
168
+
169
+
170
+ def _note(lane_id, status, agent, names, config):
171
+ label = LABELS.get(status, status)
172
+ name = _text(names.get(lane_id) or lane_id)
173
+ task = _text(agent.get('name'))
174
+ first = f'{name}: {task} — {label}' if task else f'{name} — {label}'
175
+ if len(first) > TEXT_MAX:
176
+ first = first[:TEXT_MAX - 1] + '…'
177
+ note = {'lane': lane_id, 'status': status, 'title': label,
178
+ 'message': f'{first}\nlanes-launch lane {lane_id}', 'priority': PRIORITY.get(status, 3)}
179
+ template = config.get('action_url') or ''
180
+ if template:
181
+ url = template.replace('{lane}', urllib.parse.quote(lane_id, safe=''))
182
+ note['click'] = url
183
+ note['actions'] = [{'action': 'view', 'label': 'Open lane', 'url': url}]
184
+ return note
185
+
186
+
187
+ def notify_changes(prev_status, new_status, config, names=None, now=None, clients_dir=None, devices_dir=None):
188
+ """The notifications for lanes whose agent status ENTERED needs-you between two status.json documents.
189
+ `prev_status` None = the first read: seed only, nothing is news. Empty when a desk device is present."""
190
+ if prev_status is None:
191
+ return []
192
+ before = agent_statuses(prev_status)
193
+ lanes = new_status.get('lanes') if isinstance(new_status, dict) else None
194
+ changed = [(lane_id, status) for lane_id, status in agent_statuses(new_status).items()
195
+ if status in TRIGGERS and before.get(lane_id) != status]
196
+ if not changed:
197
+ return []
198
+ now = time.time() if now is None else now
199
+ window = int(config.get('quiet_when_device_active_s', DEFAULT_QUIET) or 0)
200
+ if device_active(clients_dir if clients_dir is not None else L.STATE / 'clients', window, now, devices_dir):
201
+ return []
202
+ names = names or {}
203
+ return [_note(lane_id, status, lanes[lane_id]['agent'], names, config) for lane_id, status in sorted(changed)]
204
+
205
+
206
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
207
+ def redirect_request(self, *_args, **_kwargs): # a redirect would carry the bearer elsewhere: refuse it
208
+ return None
209
+
210
+
211
+ def default_opener():
212
+ return urllib.request.build_opener(_NoRedirect()).open
213
+
214
+
215
+ def send(note, config, opener=None):
216
+ """POST one notification as ntfy JSON to `config['url']`. True when the server accepted it. Any failure is one
217
+ stderr (journal) line naming the lane and the failure class — never the topic or the bearer — and False."""
218
+ body = {'topic': config['topic'], 'title': note['title'], 'message': note['message'],
219
+ 'priority': note.get('priority', 3)}
220
+ for key in ('click', 'actions'):
221
+ if key in note:
222
+ body[key] = note[key]
223
+ headers = {'Content-Type': 'application/json'}
224
+ if config.get('token'):
225
+ headers['Authorization'] = f"Bearer {config['token']}"
226
+ request = urllib.request.Request(config['url'], data=json.dumps(body).encode(), headers=headers, method='POST')
227
+ try:
228
+ with (opener or default_opener())(request, timeout=TIMEOUT) as response:
229
+ status = getattr(response, 'status', 200)
230
+ if not 200 <= status < 300:
231
+ raise urllib.error.HTTPError(config['url'], status, 'not accepted', {}, None)
232
+ return True
233
+ except urllib.error.HTTPError as error:
234
+ why = f'HTTP {error.code}'
235
+ except urllib.error.URLError as error:
236
+ why = f'unreachable ({type(error.reason).__name__})' if not isinstance(error.reason, str) else 'unreachable'
237
+ except TimeoutError:
238
+ why = 'timed out'
239
+ except OSError as error:
240
+ why = type(error).__name__
241
+ except Exception as error: # a push must never stop the service
242
+ why = type(error).__name__
243
+ print(f'notify: send failed for {note.get("lane")}: {why}', file=sys.stderr, flush=True)
244
+ return False
245
+
246
+
247
+ class Watcher:
248
+ """Tails status.json by mtime; sends what notify_changes returns. The config and registry are re-read when
249
+ their mtime changes. The first readable document seeds the previous one."""
250
+
251
+ def __init__(self, state, conf_path, opener=None, clock=time.time):
252
+ self.state = Path(state)
253
+ self.conf_path = Path(os.path.expanduser(str(conf_path)))
254
+ self.opener, self.clock = opener, clock
255
+ self.prev = None
256
+ self.stamps = {}
257
+ self.config = read_config(self.conf_path)
258
+ self.names = {}
259
+
260
+ def _changed(self, key, path):
261
+ try:
262
+ stamp = path.stat().st_mtime_ns
263
+ except OSError:
264
+ stamp = None
265
+ if self.stamps.get(key, 'unset') == stamp:
266
+ return False
267
+ self.stamps[key] = stamp
268
+ return True
269
+
270
+ def tick(self):
271
+ if self._changed('conf', self.conf_path):
272
+ self.config = read_config(self.conf_path)
273
+ if self._changed('registry', self.state / 'registry.json'):
274
+ self.names = lane_names(L.read_json(self.state / 'registry.json'))
275
+ if not self._changed('status', self.state / 'status.json'):
276
+ return []
277
+ document = L.read_json(self.state / 'status.json')
278
+ if not isinstance(document, dict):
279
+ return [] # a torn or missing file: keep the previous document
280
+ notes = notify_changes(self.prev, document, self.config, names=self.names, now=self.clock(),
281
+ clients_dir=self.state / 'clients', devices_dir=self.state / 'devices')
282
+ self.prev = document
283
+ if not self.config['enabled']:
284
+ return []
285
+ for note in notes:
286
+ send(note, self.config, self.opener)
287
+ return notes
288
+
289
+
290
+ def main(argv=None):
291
+ parser = argparse.ArgumentParser(prog='notify', description='push needs-you transitions to an ntfy topic')
292
+ parser.add_argument('--interval', type=float, default=3.0, help='seconds between status.json checks')
293
+ parser.add_argument('--test', action='store_true', help='send one test notification and exit (1 = not sent)')
294
+ args = parser.parse_args(argv)
295
+ conf = os.environ.get('LANES_NOTIFY_CONF') or DEFAULT_CONF
296
+ state = Path(os.environ.get('LANES_STATE') or L.STATE)
297
+ if args.test:
298
+ config = read_config(conf)
299
+ if not config['enabled']:
300
+ print(f'notify: {conf} has no valid url and topic (or its token_file is unreadable)', file=sys.stderr)
301
+ return 1
302
+ note = {'lane': 'test', 'status': 'test', 'title': 'lanes', 'message': 'lanes notify: test notification',
303
+ 'priority': 3}
304
+ return 0 if send(note, config) else 1
305
+ watcher = Watcher(state, conf)
306
+ if not watcher.config['enabled']:
307
+ print(f'notify: {conf} has no valid url and topic; tracking only until it does', file=sys.stderr, flush=True)
308
+ while True:
309
+ try:
310
+ watcher.tick()
311
+ except Exception as error: # one bad tick is a journal line; the next tick tries again
312
+ print(f'notify: {type(error).__name__}: {error}', file=sys.stderr, flush=True)
313
+ time.sleep(max(1.0, args.interval))
314
+
315
+
316
+ if __name__ == '__main__':
317
+ sys.exit(main())
@@ -81,7 +81,7 @@ def rename(lane_id, new_name):
81
81
  """Display name of one registered session — this file is its only name authority (`apply` takes the lane's
82
82
  name from the record). Only `name` changes: the id, binding, workspace and folder stay, so view targets,
83
83
  resolve files and `lane open <id>` keep working. The `user-sessions/` and `resolve.dynamic/` files an
84
- UNREGISTERED session is renamed through are never read for a registered one (DEF-1412)."""
84
+ UNREGISTERED session is renamed through are never read for a registered one (DEF-1475)."""
85
85
  name = L._title(new_name)
86
86
  with L.registry_lock():
87
87
  data = load()
@@ -72,8 +72,9 @@ def make_entry(workspace, container, socket, name, pane, previous=None):
72
72
  meta = L.read_json(L.STATE / 'user-sessions' / (entry['id'] + '.json'), {}) or {}
73
73
  if session_key(meta) == session_key(entry):
74
74
  entry['name'] = meta.get('name') or entry['name']
75
- L.atomic_write(L.STATE / 'resolve.dynamic' / entry['id'],
76
- '\n'.join(['tmux', container, socket, name, entry['name']]) + '\n')
75
+ # statusd calls this for every unregistered session on every cycle: rewrite only a changed target or name.
76
+ L.atomic_write_if_changed(L.STATE / 'resolve.dynamic' / entry['id'],
77
+ '\n'.join(['tmux', container, socket, name, entry['name']]) + '\n')
77
78
  return entry
78
79
 
79
80
 
@@ -178,7 +179,7 @@ def promote_to_registered(workspace_id, entry, registry):
178
179
  raise L.LanesError('Only tmux sessions can be promoted (zmx and exec holders are not supported)')
179
180
  # A binding any board already holds — a repository declaration or an adopted registered session — is
180
181
  # registered. Appending it again would make the repository declaration win the binding on the next sync,
181
- # re-keying an adopted lane's id and orphaning its view targets (DEF-1411). Nothing is written.
182
+ # re-keying an adopted lane's id and orphaning its view targets (DEF-1474). Nothing is written.
182
183
  if entry.get('registered_session') or session_key(entry) in {
183
184
  session_key(lane) for w in registry.get('workspaces', []) for lane in w.get('lanes', [])
184
185
  if lane.get('holder') == 'tmux'}:
@@ -244,11 +245,9 @@ def _close_session(workspace, entry):
244
245
  if not published or session_key(published) != session_key(entry) or not entry.get('closable'):
245
246
  raise L.LanesError('Session changed; reload before closing')
246
247
  if entry.get('legacy'):
247
- import legacy_sessions
248
- legacy_sessions.close(entry)
249
- clear_viewers(entry['id'])
250
- (L.STATE / 'resolve.dynamic' / entry['id']).unlink(missing_ok=True)
251
- return
248
+ # Plan Task 19 retired the pre-persistence Docker-shell tracker (legacy_sessions.py): only a status.json an
249
+ # older statusd wrote can still carry such a row. Refused before any process, tmux or file is touched.
250
+ raise L.LanesError('Legacy shells are retired; nothing was closed')
252
251
  registered = {session_key(lane) for w in registry['workspaces'] for lane in w.get('lanes', [])
253
252
  if lane.get('holder') == 'tmux'}
254
253
  if session_key(entry) in registered: