@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,764 @@
1
+ #!/usr/bin/env python3
2
+ """lanes status daemon: observes lanes, never touches them.
3
+
4
+ Every few seconds: find LIVE tmux servers via `ps` (dead sockets are never probed), run one read-only
5
+ `list-panes -a` per live server that the registry references, and publish ~/.local/state/lanes/status.json
6
+ atomically, plus the one-line ~/.local/state/lanes/health. The sidebar, `lanes status`, `lanes doctor` and the
7
+ future Throttle web harness all read those files.
8
+
9
+ It never INVENTS a state. "No answer" (a tmux, ps, docker or zmx call that timed out or failed on an overloaded
10
+ host) is not an observation: the lane keeps its last published state, marked stale, for at most STALE_MAX seconds
11
+ and is 'unknown' after that. 'absent' / 'missing' are only published when a server answered without the session,
12
+ or positively does not exist.
13
+ """
14
+ import json
15
+ import os
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
+ import lanes_lib as L # noqa: E402
22
+ import session_actions as A # noqa: E402
23
+ import legacy_sessions as Legacy # noqa: E402
24
+
25
+ INTERVAL = float(os.environ.get('LANES_STATUS_INTERVAL', '3'))
26
+ SLOWDOWN = {'ok': 1, 'busy': 2, 'critical': 4} # a host under pressure gets FEWER spawns from us, not the same
27
+ HEARTBEAT = 15 # status.json is rewritten at least this often, so its age proves that statusd is alive
28
+ PROBE_TIMEOUT = 5
29
+ CONTAINER_REFRESH = 300 # docker is asked again this often as a safety net; liveness itself comes from /proc/<pid>
30
+ CONTAINER_RETRY = 30 # ... and at most this often while a container is down or docker did not answer
31
+ ACTIVE_WINDOW = 20 # seconds since last output for a lane to count as active
32
+ STALE_MAX = 300 # a lane nobody could observe for this long is published as 'unknown', no longer as last seen
33
+ FIELDS = ['session_name', 'pane_pid', 'pane_dead', 'window_activity', 'pane_current_command',
34
+ 'pane_current_path', 'window_width', 'window_height', 'session_attached', 'pane_tty',
35
+ 'session_id', 'session_created',
36
+ # Alignment contract A4a: the group a session is attached onto (a Throttle Studio browser view is a
37
+ # grouped session), and which pane of which window represents the session's state.
38
+ 'session_group', 'pane_active', 'window_active']
39
+ FORMAT = '\t'.join('#{' + f + '}' for f in FIELDS)
40
+ VIEW_PREFIX = 'view-' # a Throttle Studio browser attach: a grouped session named view-<studio>-<ch>
41
+
42
+
43
+ def log(where, error):
44
+ print(time.strftime('%FT%T'), where, type(error).__name__, error, flush=True)
45
+
46
+
47
+ # ------------------------------------------------- the viewer rule (A4a)
48
+ # A Throttle Studio browser attaches with `new-session -t <lane> -s view-<studio>-<ch>`: an independent
49
+ # current window on the lane's windows. Such a session is a CLIENT of the lane, not a lane of its own, and
50
+ # both surfaces must say so — the Host mirrors these three functions in `studio_host::tmux`. Pure, so the
51
+ # rule is unit-testable without a server.
52
+
53
+
54
+ def viewer_of(name, group, leaders):
55
+ """The lane session that live session `name` VIEWS, or None when it is a session in its own right.
56
+
57
+ A viewer is a tmux session GROUPED onto a group whose LEADER (`leaders`, see `group_leaders`) is a
58
+ DIFFERENT session — declared or not, so a browser on an undeclared Studio-started `st_<board>_<slug>`
59
+ lane is that lane's viewer too. Viewers are counted in the viewed lane's `attached`/`viewers`, never
60
+ listed as unregistered ghosts (the old `web-<ch>` spelling was, which is the defect this rule closes).
61
+ The value returned is the LEADER's session name, which is the group's name while that leader lives and
62
+ the oldest survivor's name once it does not.
63
+
64
+ The `view-` NAME alone is deliberately NOT enough, and these shapes must stay VISIBLE because nothing
65
+ alive may be hidden: an operator's own ungrouped `tmux new -s view-notes`, and the oldest survivor of a
66
+ group whose lane was killed under it, whose panes are still running. Both fall through to the 👻 list."""
67
+ leader = leaders.get(group) if group and group != name else None
68
+ return leader if leader is not None and leader != name else None
69
+
70
+
71
+ def group_leaders(sessions):
72
+ """group name -> the live session that LEADS it, for the viewer rule. Mirrors the Host's
73
+ `inventory::group_leader` exactly, so the two surfaces cannot partition one listing differently.
74
+
75
+ tmux reports a leader's `#{session_group}` as its own name (and a session with no grouped member reports
76
+ no group at all), so the live leader is the first answer. When the named leader is GONE — a lane killed
77
+ under an attached browser, or killed and RECREATED (the recreated session reports NO group while the old
78
+ members still name it) — the group's OLDEST surviving member stands in, ties broken by name so both
79
+ surfaces choose the same one. Without the stand-in a Studio attach onto such a survivor produced a second
80
+ member of one leaderless group that NEITHER member could be seen to view, so the browser's own
81
+ `view-<studio>-<ch>` was drawn here as an openable 👻 while the Host counted it as a viewer (DEF-901;
82
+ the Host half landed in review round 5, this half in round 6, DEF-913)."""
83
+ def created(name):
84
+ try:
85
+ return int(sessions[name].get('session_created') or 0)
86
+ except (TypeError, ValueError):
87
+ return 0
88
+
89
+ leaders = {}
90
+ for group in {row.get('session_group') for row in sessions.values() if row.get('session_group')}:
91
+ row = sessions.get(group)
92
+ if row is not None and row.get('session_group') == group:
93
+ leaders[group] = group
94
+ continue
95
+ members = [name for name, member in sessions.items() if member.get('session_group') == group]
96
+ if members:
97
+ leaders[group] = min(members, key=lambda name: (created(name), name))
98
+ return leaders
99
+
100
+
101
+ def split_viewers(sessions, registered=()):
102
+ """(lanes, viewers): the live sessions that are lanes in their own right, and per viewed lane session the
103
+ list of viewer session rows. `sessions` maps session_name -> its chosen `list-panes` row.
104
+
105
+ A DECLARED session (`registered`) is always a lane of its own, whatever it is grouped onto: an operator
106
+ can bind two declared lanes with tmux's own `new-session -t <other-lane> -s <name>`, and filing the second
107
+ one as a viewer published the registry's own row `absent` while its session was alive and attached — where
108
+ the Host's declared branch (`inventory::derive_status`) reads that session's panes and says `active`.
109
+ Only UNDECLARED grouped members are viewers (DEF-914)."""
110
+ leaders = group_leaders(sessions)
111
+ lanes, viewers = {}, {}
112
+ for name, row in sessions.items():
113
+ viewed = None if name in registered else viewer_of(name, row.get('session_group', ''), leaders)
114
+ if viewed is None:
115
+ lanes[name] = row
116
+ else:
117
+ viewers.setdefault(viewed, []).append(row)
118
+ return lanes, viewers
119
+
120
+
121
+ def session_row(rows):
122
+ """The one `list-panes -a` row that represents a session: the ACTIVE pane of its ACTIVE window, else the
123
+ first listed row. The Host reads the same pane (`inventory::dead_active_pane`), so `dead` cannot mean one
124
+ thing in the sidebar and another in the Studio for a multi-window lane."""
125
+ for row in rows:
126
+ if row.get('window_active') == '1' and row.get('pane_active') == '1':
127
+ return row
128
+ return rows[0]
129
+
130
+
131
+ def newest_activity(rows):
132
+ """The newest `window_activity` across a session's panes — the same reading as the Host's
133
+ `inventory::newest_window_activity`. An agent working in window 1 keeps the lane `active` in BOTH
134
+ surfaces while window 0 idles."""
135
+ newest = 0
136
+ for row in rows:
137
+ try:
138
+ newest = max(newest, int(row['window_activity']))
139
+ except (KeyError, ValueError):
140
+ continue
141
+ return newest
142
+
143
+
144
+ def tmux_token(board):
145
+ """A board id as the Throttle Studio Host spells it inside a session name (`tmux::tmux_token`): anything
146
+ outside [A-Za-z0-9_-] becomes `_`. Every board id in the operator's registry is already [a-z-], so this
147
+ only matters for an id carrying a `.` or a `:` — where matching the raw id alone would never fire."""
148
+ return ''.join(c if (c.isascii() and (c.isalnum() or c in '_-')) else '_' for c in board)
149
+
150
+
151
+ def board_named_by(name, boards):
152
+ """The board an UNDECLARED session NAMES, or None. A Studio-started lane is `st_<board>_<slug>` and a
153
+ Studio browser view is `view-<board>-<ch>` (contract A2/A5), both in the Host's own spelling
154
+ (`tmux_token`). Used only as the PREFERENCE handed to `session_actions.workspace_for`, which still
155
+ decides; a session that names no board is attributed by its working directory as before."""
156
+ for board in sorted(boards):
157
+ for spelling in {board, tmux_token(board)}:
158
+ if name.startswith(f'st_{spelling}_') or name.startswith(f'{VIEW_PREFIX}{spelling}-'):
159
+ return board
160
+ return None
161
+
162
+
163
+ def live_tmux_sockets():
164
+ """Socket names (-L) of running tmux servers, from the process table only.
165
+ None = ps did not answer: the caller must then NOT conclude that any server is gone."""
166
+ rc, out, _err = L.run_bounded(['ps', '-eo', 'comm=,args='], 10)
167
+ if rc != 0:
168
+ return None
169
+ names = set()
170
+ for line in out.splitlines():
171
+ if not line.startswith('tmux: server'):
172
+ continue
173
+ parts = line.split()
174
+ for index, part in enumerate(parts):
175
+ if part == '-L' and index + 1 < len(parts):
176
+ names.add(parts[index + 1])
177
+ return names
178
+
179
+
180
+ def keep(previous, lane_id, outcome):
181
+ """What to publish for a lane that could not be observed: its last published entry, marked stale — or
182
+ 'unknown' when there is none, or once it has gone unobserved for STALE_MAX. Never 'absent' / 'missing'.
183
+ 'stale_since' is set once and then carried along, so a kept entry is identical from cycle to cycle (no
184
+ rewrite of status.json, no sidebar repaint) until the one change to 'unknown'."""
185
+ old = (previous.get('lanes') or {}).get(lane_id) or {}
186
+ now = time.time()
187
+ since = old.get('stale_since')
188
+ since = since if isinstance(since, (int, float)) else int(now)
189
+ if old.get('state') in (None, 'unknown') or now - since > STALE_MAX:
190
+ return {'state': 'unknown', 'observed': outcome}
191
+ # 'active' is the one state that is a claim about NOW (output in the last 20 s): never true of a cached entry,
192
+ # and the one that readers which know nothing about 'stale' count and draw green. 'idle' is the seventh word
193
+ # a pre-DEF-823 statusd published for a zmx lane: a status.json written by that build seeds `new_context()`
194
+ # across the upgrade, so it is normalised here rather than drawn as an unknown glyph by the sidebar.
195
+ state = 'quiet' if old['state'] in ('active', 'idle') else old['state']
196
+ return dict(old, state=state, stale=True, observed=outcome, stale_since=since)
197
+
198
+
199
+ def lane_entry(pane, now, activity=None, viewing=()):
200
+ """One live session's published row. `activity` is the NEWEST `window_activity` across the session's panes
201
+ (see `newest_activity`) and `viewing` the rows of the sessions that VIEW it (see `split_viewers`); both
202
+ default to "this pane alone", which is what a caller with a single row has."""
203
+ if activity is None:
204
+ try:
205
+ activity = int(pane['window_activity'])
206
+ except ValueError:
207
+ activity = 0
208
+ if pane['pane_dead'] == '1':
209
+ state = 'dead'
210
+ # INCLUSIVE, on the integer second: `#{window_activity}` is whole seconds, this daemon's clock is a float,
211
+ # and the Host compares integer seconds with `age <= ACTIVE_WINDOW_SECS` (`inventory::from_window_activity`).
212
+ # A strict float `<` made a lane exactly ACTIVE_WINDOW seconds idle `quiet` here and `active` in the Studio
213
+ # (DEF-885, re-lost to the #347 merge and re-landed as DEF-932).
214
+ elif int(now) - activity <= ACTIVE_WINDOW:
215
+ state = 'active'
216
+ else:
217
+ state = 'quiet'
218
+ entry = {'state': state, 'activity': activity, 'command': pane['pane_current_command'],
219
+ 'cwd': pane['pane_current_path'], 'pid': int(pane['pane_pid'] or 0),
220
+ 'size': f"{pane['window_width']}x{pane['window_height']}",
221
+ # Clients on the session itself plus the clients of every session that views it.
222
+ 'attached': int(pane['session_attached'] or 0)
223
+ + sum(int(row.get('session_attached') or 0) for row in viewing),
224
+ 'tty': pane['pane_tty']}
225
+ if viewing: # skipped when 0, so a lane nobody views publishes byte-identically to before A4a
226
+ entry['viewers'] = len(viewing)
227
+ return entry
228
+
229
+
230
+ def probe_server(info, socket, live):
231
+ if not info.get('pid'):
232
+ # Only a docker inspect that ANSWERED "pid 0" says the container is down. No record at all, or one that
233
+ # carries an error (docker did not answer) = nobody knows.
234
+ return ('noserver' if info and not info.get('error') else 'error'), ''
235
+ if live is not None and socket not in live:
236
+ return 'noserver', '' # ps answered and lists no such server: a stale socket file is never probed
237
+ if (live is not None and info.get('running', True) and os.path.exists(f"{L.proc_root()}/{info['pid']}")
238
+ and not os.path.exists(L.tmux_socket(info, socket))):
239
+ # (container init alive: a container that went DOWN takes its sockets along and is 'missing', not this)
240
+ # The server process runs but its socket file is gone: somebody emptied the container's /tmp (2026-09-20,
241
+ # 04:24: every lane alive, every viewer saying "not running"). Nothing can be asked, nothing died.
242
+ return 'nosocket', ''
243
+ return L.tmux_probe(info, socket, ['list-panes', '-a', '-F', FORMAT], timeout=PROBE_TIMEOUT)
244
+
245
+
246
+ def discovered_sockets(containers, live):
247
+ """Every LIVE `IDENT`-named tmux socket file in these workspace containers, as (container, socket).
248
+
249
+ The one discovery rule, read by `observe` (which lists their sessions as unregistered rows) and by
250
+ `reconcile_client_sizes` (which owes those sessions the same size policy — a lanes view can show any row
251
+ the sidebar draws, and the sockets a Studio-started lane may land on are by construction ones no
252
+ declaration names). Never starts a server to ask whether one exists: a socket file that is not in `live`
253
+ (statusd's process-table reading) is not probed.
254
+ """
255
+ found = []
256
+ for container, info in containers.items():
257
+ if not info.get('pid'):
258
+ continue
259
+ directory = Path(L.tmux_socket(info, 'discovery')).parent
260
+ try:
261
+ for path in sorted(directory.iterdir()):
262
+ if L.IDENT.fullmatch(path.name) and path.is_socket() and (live is None or path.name in live):
263
+ found.append((container, path.name))
264
+ except OSError:
265
+ pass
266
+ return found
267
+
268
+
269
+ def observe(registry, containers, live, previous=None):
270
+ """previous = the last published payload: what a lane keeps when its server gave no answer this time."""
271
+ previous = previous or {}
272
+ now = time.time()
273
+ lanes, servers, unregistered = {}, {}, {}
274
+ wanted, owners, counts = {}, {}, {}
275
+ for workspace in registry['workspaces']:
276
+ for lane in workspace['lanes']:
277
+ if lane['holder'] == 'tmux':
278
+ key = (lane['container'], lane['tmux']['socket'])
279
+ wanted.setdefault(key, []).append(lane)
280
+ owners.setdefault(key, set()).add(workspace['id'])
281
+ counts.setdefault(key, {})
282
+ counts[key][workspace['id']] = counts[key].get(workspace['id'], 0) + 1
283
+ prior = {}
284
+ for workspace_id, entries in (previous.get('unregistered') or {}).items():
285
+ for entry in entries:
286
+ if entry.get('holder') != 'tmux':
287
+ continue
288
+ key = A.session_key(entry)
289
+ # One session is ONE entry, listed in every owner's sidebar, so the board it was ATTRIBUTED to is
290
+ # the one it carries — not whichever listing this loop reached last.
291
+ #
292
+ # `shared` marks the verdict "no board could claim this one", and it is NOT a preference: the
293
+ # board such an entry carries was minted by the tally rule to own its id, and handing THAT back to
294
+ # `workspace_for` as the preference made it claimable on the next cycle (`preferred in by_id`
295
+ # returns at once) — so the multi-owner listing lasted exactly one INTERVAL and every other
296
+ # owner's sidebar lost the row again, the very drop DEF-915 closed (DEF-976). The verdict is
297
+ # recomputed from scratch each cycle instead; the id stays the one `old` carries.
298
+ prior[key] = (None if entry.get('shared') else (entry.get('workspace') or workspace_id), entry)
299
+ wanted.setdefault(key[:2], [])
300
+ # Discovery covers every live socket in these workspace containers, including shared/default
301
+ # servers and servers with no registered lane. Never start a server to ask whether it exists.
302
+ for key in discovered_sockets(containers, live):
303
+ wanted.setdefault(key, [])
304
+ for (container, socket), members in wanted.items():
305
+ info = containers.get(container) or {}
306
+ rows_by_session = {}
307
+ # One server (or one odd row) must not cost the other servers their cycle: whatever goes wrong in here
308
+ # ends as "no answer from THIS server".
309
+ try:
310
+ outcome, text = probe_server(info, socket, live)
311
+ for row in text.splitlines() if outcome == 'ok' else []:
312
+ values = dict(zip(FIELDS, row.split('\t')))
313
+ rows_by_session.setdefault(values['session_name'], []).append(values)
314
+ except Exception as error:
315
+ log(f'observe {container}/{socket}', error)
316
+ outcome, rows_by_session = 'error', {}
317
+ # A4a: every session is read through its ACTIVE pane and its NEWEST window activity, the same two
318
+ # readings the Host makes, so `dead`/`active` cannot mean one thing per surface on a multi-window lane.
319
+ sessions = {name: session_row(rows) for name, rows in rows_by_session.items()}
320
+ activities = {name: newest_activity(rows) for name, rows in rows_by_session.items()}
321
+ # ... and an UNDECLARED session grouped onto another live session is that session's viewer, not a lane.
322
+ registered = {lane['tmux']['session'] for lane in members}
323
+ own_sessions, viewers = split_viewers(sessions, registered)
324
+ answered = outcome in ('ok', 'noserver')
325
+ last = (previous.get('servers') or {}).get(f'{container}/{socket}') or {}
326
+ servers[f'{container}/{socket}'] = {'live': outcome == 'ok' if answered else last.get('live'),
327
+ 'sessions': len(own_sessions) if answered else last.get('sessions', 0),
328
+ 'outcome': outcome}
329
+ if not answered:
330
+ # The sessions nobody registered stay visible as well while their server does not answer.
331
+ for workspace_id, entries in (previous.get('unregistered') or {}).items():
332
+ for entry in entries:
333
+ if entry.get('container') == container and (entry.get('tmux') or {}).get('socket') == socket:
334
+ unregistered.setdefault(workspace_id, []).append(entry)
335
+ lanes[entry['id']] = keep(previous, entry['id'], outcome)
336
+ if own_sessions:
337
+ # Nothing that is alive may be invisible: list sessions no registry mentions — except a VIEWER of a
338
+ # session on this socket (a grouped attach, Throttle Studio's `view-…` among them), which is that
339
+ # session's client, not a ghost.
340
+ for name in sorted(set(own_sessions) - registered):
341
+ try:
342
+ pane = own_sessions[name]
343
+ old_ws, old = prior.get((container, socket, name), (None, None))
344
+ board_ids = {w['id'] for w in registry['workspaces']}
345
+ workspace_id = A.workspace_for(registry, container, socket, pane['pane_current_path'],
346
+ owners.get((container, socket), set()),
347
+ old_ws or board_named_by(name, board_ids))
348
+ listed = [workspace_id] if workspace_id else []
349
+ if not workspace_id:
350
+ # `workspace_for` proved no single board: several declare lanes on this socket and the
351
+ # session names none of them and sits under no board's roots. Dropping it (main's rule,
352
+ # taken back wholesale by the #347 merge) is the one thing this daemon may not do —
353
+ # "nothing alive is ever hidden" — so it is listed in EVERY owner's sidebar, with its
354
+ # id owned by the board declaring the most lanes on the socket, ties by name. ONE
355
+ # session stays ONE id, ONE resolve file and ONE status row however many boards show
356
+ # it (DEF-884, re-lost to the merge and re-landed as DEF-915).
357
+ share = sorted(owners.get((container, socket)) or ()) or sorted(
358
+ w['id'] for w in registry['workspaces']
359
+ if w.get('container') == container
360
+ or any(x.get('container') == container for x in w.get('lanes', [])))
361
+ if not share:
362
+ log(f'unassigned {container}/{socket}/{name}',
363
+ ValueError(pane['pane_current_path']))
364
+ continue
365
+ tally = counts.get((container, socket), {})
366
+ workspace_id = min(share, key=lambda x: (-tally.get(x, 0), x))
367
+ listed = share
368
+ entry = A.make_entry(workspace_id, container, socket, name, pane, old)
369
+ if len(listed) > 1:
370
+ # The verdict, carried on the entry so the NEXT cycle does not read the tally winner
371
+ # as a board that claimed it (DEF-976). Readers of status.json see one more key on a
372
+ # row they already had; the sidebar draws the row, never this field.
373
+ entry['shared'] = True
374
+ # A name outside the DECLARATION grammar (`(`, `)`, `/`, `,` — the `<name> (drain)` twins
375
+ # among them) is listed like any other ghost, not dropped: the grammar governs what may be
376
+ # DECLARED, never what may be SEEN, and `holders/tmux.sh` opens the raw name with
377
+ # `-t "=$session"` (its own gate admits exactly what tmux itself accepts, DEF-891), and
378
+ # `session_actions.close_session` closes it by session id (DEF-916). The `note` is carried
379
+ # in status.json for readers of the document; no surface draws it today, and neither the
380
+ # RUNBOOK nor the design doc may promise that it does.
381
+ if not L.TMUX_SESSION.match(name):
382
+ entry['note'] = 'session name outside the lane declaration grammar'
383
+ found = lane_entry(pane, now, activities.get(name), viewers.get(name, []))
384
+ except Exception as error:
385
+ log(f'observe {container}/{socket}/{name}', error)
386
+ continue
387
+ for board_id in listed:
388
+ unregistered.setdefault(board_id, []).append(entry)
389
+ lanes[entry['id']] = found
390
+ for lane in members:
391
+ session = lane['tmux']['session']
392
+ pane = own_sessions.get(session)
393
+ if not answered:
394
+ lanes[lane['id']] = keep(previous, lane['id'], outcome)
395
+ elif pane is None:
396
+ # 'absent' = the container is up but this session (or its whole tmux server) does not
397
+ # exist right now, e.g. a completed orchestrator turn; 'missing' = the container is down.
398
+ lanes[lane['id']] = {'state': 'absent' if info.get('running') else 'missing'}
399
+ else:
400
+ try:
401
+ lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, []))
402
+ except Exception as error: # a torn row: the session is there, its details are not readable
403
+ log(f"observe {lane['id']}", error)
404
+ lanes[lane['id']] = keep(previous, lane['id'], 'error')
405
+ return lanes, servers, unregistered
406
+
407
+
408
+ VIEWER_TERM = 'xterm-lanesview' # TERM alias used by holders/tmux.sh; visible as #{client_termname}
409
+
410
+
411
+ CLIENT_FORMAT = '#{client_name}|#{session_name}|#{session_group}|#{client_termname}|#{client_flags}'
412
+
413
+ # The Throttle Studio Host's own grouped session name (`studio_host::tmux::WebSession`):
414
+ # `view-<studio>-<ch>`, plus the legacy `web-<ch>` it still recognises. The Host owns those clients' size
415
+ # mode and un-flags them itself, so this daemon only ever ADDS the flag to them. Every other client — the
416
+ # operator's editors, and an operator's own grouped scratch attach — gets the size back here. The test is
417
+ # `lanes_lib.is_host_view`, the MIRROR of `WebSession::channel_of`; the `view-`/`web-` PREFIX this used to
418
+ # read is strictly WIDER than anything the Host mints, so an operator's grouped `view-notes` was flagged
419
+ # here and un-flagged by nobody — the DEF-852 stranding, surviving for a whole class of names (DEF-980).
420
+ is_host_view = L.is_host_view
421
+
422
+
423
+ def client_size_plan(rows):
424
+ """The `refresh-client` calls `reconcile_client_sizes` would make, as
425
+ (client_name, lane shown, is_lanes_view, was_ignoring, wanted_flag) — pure, so the rule is unit-testable
426
+ without a tmux server. `rows` are CLIENT_FORMAT lines already split on '|'.
427
+
428
+ Clients are bucketed by the lane they SHOW, not by their own `#{session_name}`: grouped sessions share the
429
+ lane's windows, so a client attached to `view-<studio>-<ch>` drives the lane's size under
430
+ `window-size latest` exactly like a client on the lane itself. Keying on `session_name` put such a client
431
+ in a bucket of its own, where no lanes view is ever present — so a browser `take` attach kept resizing the
432
+ operator's lanes view, and a browser `ignore` attach had the Host's `-f ignore-size` STRIPPED on the next
433
+ tick (MEASURED, 2026-09-20)."""
434
+ names = [row[0] for row in rows]
435
+ by_lane = {}
436
+ for name, session, group, term, flags in rows:
437
+ shows = group if group and group != session else session
438
+ by_lane.setdefault(shows, []).append(
439
+ (name, session, term == VIEWER_TERM, 'ignore-size' in flags.split(',')))
440
+ plan = []
441
+ for shows, clients in sorted(by_lane.items()):
442
+ viewed = any(is_viewer for _n, _s, is_viewer, _f in clients)
443
+ for name, session, is_viewer, ignoring in clients:
444
+ # Host and container pty NAMES can collide; a name that appears twice cannot be targeted safely.
445
+ if is_viewer or names.count(name) > 1 or ignoring == viewed:
446
+ continue
447
+ # A Studio browser client on its OWN grouped `view-<studio>-<ch>` session chose `ignore-size` for
448
+ # itself; only a lanes view may add the flag, and the Host un-flags that client itself
449
+ # (`studio_host::tmux::refresh_client_take_size_argv`) when no lanes view shows the lane. The
450
+ # exemption needs BOTH halves: grouped (an operator's ungrouped `view-notes` is a lane in its own
451
+ # right, whose editors must get the size back) AND a session name the HOST mints — the whole
452
+ # `channel_of` grammar, not the prefix. Keying on grouped alone exempted an operator's own
453
+ # `tmux new-session -t <lane> -s scratch` client — a shape `viewer_of` explicitly admits — which
454
+ # this daemon FLAGS when a lanes view arrives and then never un-flags, because nobody else
455
+ # un-flags a client that is not the Host's (DEF-852); keying on the PREFIX left the identical
456
+ # stranding for every grouped `view-notes`/`web-dev` the Host never minted (DEF-980).
457
+ if not viewed and session != shows and is_host_view(session):
458
+ continue
459
+ plan.append((name, shows, is_viewer, ignoring, 'ignore-size' if viewed else '!ignore-size'))
460
+ return plan
461
+
462
+
463
+ def reconcile_client_sizes(registry, containers, live):
464
+ """Size policy "the lanes view wins": while a lanes-view client shows a lane, every other client of that lane
465
+ (Cursor / VS Code terminals, ad-hoc attaches, a Throttle Studio browser) is put into tmux's ignore-size; once
466
+ no viewer shows it, the editors get the size back. Only viewer-client flags are touched — never a lane, a pane
467
+ or an agent. The rule itself is `client_size_plan`."""
468
+ if live is None or (L.STATE / 'flags/coexist').exists():
469
+ return 0
470
+ changed = 0
471
+ # Every socket `observe` reads, not only the ones registered lanes bind. A lanes view can show any row the
472
+ # sidebar draws, and the sidebar draws every DISCOVERED session (a `New +` shell lives on
473
+ # `lanes-shells-<board>`, which no lane declares; a Studio-started lane on a Host-owned
474
+ # `[inventory.board_sockets]` socket is by construction on a socket no source declares) — so the policy
475
+ # "the lanes view wins" covered exactly the lanes the design claimed it covered, minus those (DEF-964).
476
+ # A discovered socket is governed EXACTLY like a registered one, both halves: a lanes view showing one of
477
+ # its lanes flags the other clients, and — once no view shows the lane — any client still carrying
478
+ # `ignore-size` is un-flagged, whoever set the flag. That second half is not free, and DEF-964's "a
479
+ # discovered socket with no viewer costs one `list-clients` and changes nothing" was false for a client
480
+ # an operator had put into `-f ignore-size` by hand on a socket the harness had never touched before
481
+ # (DEF-982). It is kept deliberately: emitting only the flagging half here would re-create DEF-852 on
482
+ # these sockets, where nothing else un-flags. Only client size flags are touched — never a lane, a pane
483
+ # or an agent.
484
+ sockets = {(lane['container'], lane['tmux']['socket']) for w in registry['workspaces'] for lane in w['lanes']
485
+ if lane['holder'] == 'tmux'}
486
+ sockets.update(discovered_sockets(containers, live))
487
+ for container, socket in sorted(sockets):
488
+ info = containers.get(container) or {}
489
+ if socket not in live or not info.get('pid'):
490
+ continue
491
+ output = L.tmux_query(info, socket, ['list-clients', '-F', CLIENT_FORMAT])
492
+ rows = [row.split('|') for row in (output or '').splitlines() if row.count('|') == 4]
493
+ for name, _shows, _is_viewer, _ignoring, want in client_size_plan(rows):
494
+ L.tmux_query(info, socket, ['refresh-client', '-t', name, '-f', want])
495
+ changed += 1
496
+ return changed
497
+
498
+
499
+ def kept_zmx(previous, outcome, container=None):
500
+ """The zmx lanes published last time (of one container, or all of them), marked stale."""
501
+ lanes, groups = {}, {}
502
+ for workspace, entries in (previous.get('zmx') or {}).items():
503
+ for entry in entries:
504
+ if container is None or entry.get('container') == container:
505
+ groups.setdefault(workspace, []).append(entry)
506
+ lanes[entry['id']] = keep(previous, entry['id'], outcome)
507
+ return lanes, groups
508
+
509
+
510
+ def zmx_state(agent):
511
+ """`(state, evidence)` for a zmx-held lane, from its lane-run agent sidecar.
512
+
513
+ `lane-run` writes `phase: agent` BEFORE it execs the agent command and `phase: shell` the instant that
514
+ command ends (MEASURED against `lanes/container/lane-run` with a scratch LANE_ROOT), so a lane that was
515
+ started through `lane new` ALWAYS has a sidecar while its session lives. A present sidecar is therefore
516
+ `quiet`, whatever its phase — never `active`: no window-activity evidence exists for a zmx lane, and
517
+ `phase: shell` means the agent ended while the lane's login shell runs on, which is the same shape a tmux
518
+ lane publishes `quiet` for.
519
+
520
+ An ABSENT or unreadable sidecar is `unknown`: nothing about the program is known. It used to be `dead`
521
+ with the sentence "the agent command ended", which `lane-run` cannot produce — the shapes that reach it
522
+ are a session made by raw `zmx attach` (never rooted in lane-run) or a deleted/unparseable state file
523
+ (DEF-893). A non-`agent` phase used to publish `idle`, a seventh word this daemon alone minted (DEF-823).
524
+ Pure, so both halves are unit-testable without a zmx daemon."""
525
+ if agent:
526
+ return 'quiet', f'lane-run agent phase {str(agent.get("phase") or "unset")!r}'
527
+ return 'unknown', 'no lane-run agent sidecar: this session was not started through `lane new`'
528
+
529
+
530
+ def observe_zmx_container(container):
531
+ """-> (lanes, groups) of one container, or None when `zmx list` did not answer in time. Raises when it failed."""
532
+ lanes, groups = {}, {}
533
+ link = L.Path.home() / '.lz' / container
534
+ binaries = sorted((link / 'bin').glob('zmx-*')) if (link / 's').is_dir() else []
535
+ if not binaries:
536
+ return lanes, groups
537
+ env = {'PATH': '/usr/bin:/bin', 'HOME': str(L.Path.home()), 'ZMX_DIR': str(link / 's'), 'LANG': 'C.UTF-8'}
538
+ rc, out, err = L.run_bounded([str(binaries[-1]), 'list'], 15, env=env)
539
+ if rc is None:
540
+ return None
541
+ if rc != 0:
542
+ # Only rc 0 is an answer. A fork refused under memory pressure (127) or a child taken by the OOM killer
543
+ # (-9) has empty output as well — parsed, that reads "this container has no zmx lanes" and they all vanish.
544
+ raise L.LanesError(f'zmx list: rc {rc} {err.strip()[:200]}')
545
+ for line in out.splitlines():
546
+ fields = {k.strip(' →*>'): v for k, v in (part.split('=', 1) for part in line.split('\t') if '=' in part)}
547
+ name = fields.get('name', '')
548
+ if not L.IDENT.match(name):
549
+ continue
550
+ meta = L.read_json(link / 'meta' / f'{name}.json', {}) or {}
551
+ agent = L.read_json(link / 'state' / f'{name}.json', {}) or {}
552
+ workspace = meta.get('ws') or fields.get('ws') or '-'
553
+ lane_id = f'{workspace}--z-{name}'
554
+ version = str(meta.get('zmx_version') or binaries[-1].name.split('-', 1)[1])
555
+ groups.setdefault(workspace, []).append({
556
+ 'id': lane_id, 'name': meta.get('title') or name, 'holder': 'zmx', 'container': container,
557
+ 'zmx': {'name': name, 'version': version}, 'color': L.ANSI_SLOTS.get(meta.get('color')),
558
+ 'icon': L.LANE_ICONS.get(meta.get('icon'), '🧵')})
559
+ L.atomic_write(L.STATE / 'resolve.dynamic' / lane_id,
560
+ '\n'.join(['zmx', container, name, version, meta.get('title') or name]) + '\n')
561
+ state, evidence = zmx_state(agent)
562
+ lanes[lane_id] = {'state': state, 'evidence': evidence,
563
+ 'clients': int(fields.get('clients', 0) or 0), 'pid': int(fields.get('pid', 0) or 0),
564
+ 'agent': agent, 'cwd': meta.get('cwd', '')}
565
+ return lanes, groups
566
+
567
+
568
+ def observe_zmx(containers, previous=None):
569
+ """zmx-held lanes describe themselves (labels + meta sidecar): no registry edit is needed to see one."""
570
+ lanes, groups = {}, {}
571
+ for container in containers:
572
+ try:
573
+ found = observe_zmx_container(container) or kept_zmx(previous or {}, 'timeout', container)
574
+ except Exception as error:
575
+ log(f'zmx {container}', error)
576
+ found = kept_zmx(previous or {}, 'error', container)
577
+ found_lanes, found_groups = found
578
+ lanes.update(found_lanes)
579
+ for workspace, entries in found_groups.items():
580
+ groups.setdefault(workspace, []).extend(entries)
581
+ return lanes, groups
582
+
583
+
584
+ def pid_alive(pid):
585
+ return bool(pid) and os.path.exists(f'{L.proc_root()}/{pid}')
586
+
587
+
588
+ def update_containers(ctx, registry, level):
589
+ """Container liveness is one stat of /proc/<init-pid> per cycle. docker is asked only for a reason — the
590
+ registry names other containers, a recorded pid vanished, a container is down / was never answered for, or the
591
+ 5-minute safety net on a host that is not busy — and at most every CONTAINER_RETRY seconds. NEVER because a
592
+ probe failed: `docker inspect` (2 spawns, 20 s timeouts) every 3 s is a load generator on a host that is
593
+ already too slow to answer it."""
594
+ names = {lane['container'] for w in registry['workspaces'] for lane in w['lanes'] + w['extras']}
595
+ known = ctx['containers']
596
+ age = time.time() - ctx['containers_at']
597
+ unsettled = set(known) != names or any(not pid_alive(c.get('pid')) for c in known.values())
598
+ if age > CONTAINER_RETRY and (unsettled or (age > CONTAINER_REFRESH and level == 'ok')):
599
+ try:
600
+ ctx['containers'] = L.refresh_containers(names)
601
+ finally:
602
+ # Stamped AFTER the (possibly slow) docker round so that the pause between two rounds is real, and also
603
+ # when it raised: a failing refresh must not become the next retry storm.
604
+ ctx['containers_at'] = time.time()
605
+ for info in ctx['containers'].values():
606
+ info['running'] = pid_alive(info.get('pid'))
607
+
608
+
609
+ def build_payload(ctx, level, reconcile=True):
610
+ """One observation pass -> the payload of status.json (None = there is no registry to observe). Every phase
611
+ fails alone: what it could not observe is republished as last seen (stale), the other phases still run."""
612
+ previous = ctx['payload']
613
+ try:
614
+ stamp = os.stat(L.STATE / 'registry.json').st_mtime_ns
615
+ if stamp != ctx['stamp']:
616
+ ctx['registry'], ctx['stamp'] = L.load_registry(), stamp
617
+ except Exception as error: # keep observing with the last registry that loaded
618
+ log('registry', error)
619
+ registry = ctx['registry']
620
+ if registry is None:
621
+ return None
622
+ try:
623
+ update_containers(ctx, registry, level)
624
+ except Exception as error:
625
+ log('containers', error)
626
+ containers = ctx['containers']
627
+ try:
628
+ live = live_tmux_sockets()
629
+ except Exception as error:
630
+ log('ps', error)
631
+ live = None
632
+ try:
633
+ lanes, servers, unregistered = observe(registry, containers, live, previous)
634
+ except Exception as error:
635
+ log('observe', error)
636
+ lanes = {lane_id: keep(previous, lane_id, 'error') for lane_id in previous.get('lanes') or {}}
637
+ servers, unregistered = previous.get('servers') or {}, previous.get('unregistered') or {}
638
+ # Half of all our spawns, and only cosmetics (who dictates a window size): not on a host under pressure.
639
+ if reconcile and level == 'ok':
640
+ try:
641
+ reconcile_client_sizes(registry, containers, live)
642
+ except Exception as error:
643
+ log('reconcile', error)
644
+ try:
645
+ zmx_lanes, zmx_groups = observe_zmx(containers, previous)
646
+ except Exception as error:
647
+ log('zmx', error)
648
+ zmx_lanes, zmx_groups = kept_zmx(previous, 'error')
649
+ lanes.update(zmx_lanes)
650
+ legacy_lanes, legacy_groups = Legacy.observe()
651
+ lanes.update(legacy_lanes)
652
+ for workspace, entries in legacy_groups.items():
653
+ unregistered.setdefault(workspace, []).extend(entries)
654
+ return {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
655
+ 'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
656
+
657
+
658
+ def new_context():
659
+ # Seeded from the file on disk: a statusd that (re)starts on a slow host still knows every lane's last state.
660
+ # The age of that file counts as time without an observation (keep() ages from 'stale_since'): a statusd that
661
+ # was down for a day must not republish yesterday's states as the last known ones.
662
+ seed = L.read_json(L.STATE / 'status.json', {}) or {}
663
+ written = float(seed.get('updated_at') or 0)
664
+ for entry in (seed.get('lanes') or {}).values():
665
+ entry.setdefault('stale_since', int(written))
666
+ return {'registry': None, 'stamp': 0, 'containers': {}, 'containers_at': 0.0,
667
+ 'payload': seed, 'published': None, 'status_at': written}
668
+
669
+
670
+ def cycle(ctx, reconcile=True):
671
+ """Observe once and ALWAYS publish -> (health level, payload or None)."""
672
+ started = time.time()
673
+ try:
674
+ health = L.compute_health()
675
+ except Exception as error:
676
+ log('health', error)
677
+ health = {}
678
+ level = health.get('level', 'ok')
679
+ try:
680
+ payload = build_payload(ctx, level, reconcile)
681
+ except Exception as error:
682
+ log('cycle', error)
683
+ payload = None
684
+ if payload is not None:
685
+ ctx['payload'] = payload
686
+ # Rewrite only on change (plus a heartbeat) so sidebars do not repaint for nothing. `health` changes every
687
+ # cycle and is therefore not part of the comparison.
688
+ if payload != ctx['published'] or started - ctx['status_at'] > HEARTBEAT:
689
+ try:
690
+ written = time.time()
691
+ L.atomic_write(L.STATE / 'status.json', json.dumps(dict(payload, health=health, updated_at=written)) + '\n')
692
+ ctx['published'], ctx['status_at'] = payload, written
693
+ except Exception as error:
694
+ log('status.json', error)
695
+ try: # "servers alive, sockets deleted" as a flag file: view.sh, the sidebar and doctor read it without parsing
696
+ lost = sorted(key for key, server in ((payload or {}).get('servers') or {}).items() if server.get('outcome') == 'nosocket')
697
+ flag = L.STATE / 'flags' / 'sockets-lost'
698
+ if lost:
699
+ L.atomic_write(flag, '\n'.join(lost) + '\n')
700
+ elif payload is not None and flag.exists():
701
+ flag.unlink()
702
+ except Exception as error:
703
+ log('sockets-lost flag', error)
704
+ if health: # no health computed = no line written: its age then tells the readers, which is the truth
705
+ try:
706
+ L.atomic_write(L.STATE / 'health', L.health_line(health, ctx['status_at']))
707
+ except Exception as error:
708
+ log('health file', error)
709
+ return level, payload
710
+
711
+
712
+ def pause(level, elapsed):
713
+ return max(0.5, INTERVAL * SLOWDOWN.get(level, 1) - elapsed)
714
+
715
+
716
+ def _registry_sources(registry):
717
+ """All terminals.json paths referenced by the current registry (unique, extant)."""
718
+ seen = set()
719
+ for w in (registry or {}).get('workspaces', []):
720
+ for lane in w.get('lanes', []):
721
+ src = lane.get('source')
722
+ if src and src not in seen:
723
+ seen.add(src)
724
+ yield src
725
+
726
+
727
+ def main():
728
+ ctx = new_context()
729
+ last_triggered_sync = 0.0
730
+ source_mtimes = {} # path -> mtime_ns at last check
731
+ while True:
732
+ started = time.time()
733
+ level, _payload = cycle(ctx)
734
+ # Hot-reload: check if any terminals.json changed since we last synced.
735
+ # Rate-limited to at most one triggered sync per 10 s to avoid thrashing.
736
+ if time.time() - last_triggered_sync > 10 and ctx['registry']:
737
+ for src in _registry_sources(ctx['registry']):
738
+ try:
739
+ mtime = os.stat(src).st_mtime_ns
740
+ except OSError:
741
+ continue
742
+ if src in source_mtimes and mtime != source_mtimes[src]:
743
+ try:
744
+ import subprocess as _sp
745
+ lanes_bin = str(L.REPO / 'bin' / 'lanes')
746
+ _sp.Popen([lanes_bin, 'sync'], stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
747
+ last_triggered_sync = time.time()
748
+ except Exception as sync_err:
749
+ log('hot-sync', sync_err)
750
+ break
751
+ source_mtimes[src] = mtime
752
+ time.sleep(pause(level, time.time() - started))
753
+
754
+
755
+ if __name__ == '__main__':
756
+ if len(sys.argv) > 1 and sys.argv[1] == '--once':
757
+ # The same builder as the daemon, minus the one phase that is not an observation (client size flags).
758
+ _level, result = cycle(new_context(), reconcile=False)
759
+ if result is None:
760
+ sys.exit('statusd --once: nothing published (no registry yet? run `lanes sync`)')
761
+ print(json.dumps({'lanes': len(result['lanes']),
762
+ 'unregistered': {k: [e['name'] for e in v] for k, v in result['unregistered'].items()}}, indent=1))
763
+ else:
764
+ main()