@vibelord/lanes 1.0.2 → 1.0.4

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 (36) hide show
  1. package/README.md +3 -0
  2. package/package.json +1 -1
  3. package/payload/lanes/README.md +58 -21
  4. package/payload/lanes/VERSION +1 -1
  5. package/payload/lanes/bin/lanes +21 -8
  6. package/payload/lanes/container/env.sh +1 -1
  7. package/payload/lanes/container/lane +5 -5
  8. package/payload/lanes/container/lane-signal +4 -4
  9. package/payload/lanes/docs/ARCHITECTURE.md +16 -8
  10. package/payload/lanes/docs/DESIGN.md +3 -3
  11. package/payload/lanes/docs/INSTALL.md +38 -2
  12. package/payload/lanes/docs/RUNBOOK.md +57 -27
  13. package/payload/lanes/docs/SHARING.md +239 -4
  14. package/payload/lanes/docs/USERGUIDE.md +1239 -0
  15. package/payload/lanes/holders.lock +10 -0
  16. package/payload/lanes/install/enable-studio-host.sh +14 -0
  17. package/payload/lanes/install/host-install.sh +5 -2
  18. package/payload/lanes/install/services.py +101 -4
  19. package/payload/lanes/install.sh +1 -1
  20. package/payload/lanes/libexec/agent_state.py +1 -1
  21. package/payload/lanes/libexec/cursor_sync.py +2 -2
  22. package/payload/lanes/libexec/economy_poll.py +3 -3
  23. package/payload/lanes/libexec/lanes_lib.py +1 -1
  24. package/payload/lanes/libexec/notify.py +3 -3
  25. package/payload/lanes/libexec/registrations.py +1 -1
  26. package/payload/lanes/libexec/session_actions.py +1 -1
  27. package/payload/lanes/libexec/sidebar.py +7 -7
  28. package/payload/lanes/libexec/statusd.py +25 -10
  29. package/payload/lanes/libexec/studio_host.py +1608 -0
  30. package/payload/lanes/systemd/lanes-economy.service +1 -1
  31. package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
  32. package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
  33. package/payload/lanes/systemd/lanes-studio-host.service +37 -0
  34. package/payload/lanes/web/README.md +2 -2
  35. package/payload/lanes/zellij/config.kdl +1 -1
  36. package/payload/manifest.json +4 -3
@@ -6,3 +6,13 @@ zmx 0.8.1 dfd75720b942466f28870731cc86dbc07afa72fb8f3bd5eeb4ff707e4eecebe8 https
6
6
  # glow 3.0.0 (markdown preview, `lanes preview`): tarball digest matches GitHub's asset digest and the release's checksums.txt;
7
7
  # the binary sits in glow_3.0.0_Linux_x86_64/. Installed only when no glow is on PATH; without either, the preview uses less -R.
8
8
  glow 3.0.0 13e05e4b2acc18d2aee44291aefe6325b077ec321b631a0cfa780e8e3bc33f78 https://github.com/charmbracelet/glow/releases/download/v3.0.0/glow_3.0.0_Linux_x86_64.tar.gz
9
+ #
10
+ # OPT-IN pins carry three more fields and are skipped by host-install.sh; the step that needs one installs it:
11
+ # name version sha256(tarball) url opt-in member-inside-the-tarball sha256(member)
12
+ # throttle-rc is the co-located Studio Host (docs/SHARING.md "Co-located Studio Host"; `lanes studio-host install`
13
+ # downloads, checks BOTH digests and selects it). It comes from a `node/<sha12>` GitHub prerelease
14
+ # (docs/runbooks/node-release-asset.md): version = node-<sha12>, the asset sidecars give both digests. No
15
+ # node/<sha12> release containing the co-location CLI exists yet, so no line is active: until one is tagged, install
16
+ # a locally built binary with `lanes studio-host install --from PATH [--sha256 HEX]` (also sha256-verified).
17
+ # Template (fill from the release's two .sha256 sidecars, then uncomment):
18
+ # throttle-rc node-<sha12> <tarball-sha256> https://github.com/bwllaming/bw-tools/releases/download/node/<sha12>/throttle-rc-x86_64-unknown-linux-gnu.tar.gz opt-in throttle-rc-x86_64-unknown-linux-gnu/throttle-rc <binary-sha256>
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+ # Opt-in: run the Throttle Studio Host co-located on this lanes server (docs/SHARING.md "Co-located Studio Host").
3
+ # As the workspace account. Writes the Host config in lanes-server mode if absent, publishes this machine's PUBLIC
4
+ # key for the deploy, adopts a deploy receipt or enrols with a registration key file, and enables
5
+ # lanes-studio-host.service — or leaves it enabled and WAITING for enrolment (exit 0; exit 3 when a receipt or key
6
+ # was refused, the units still enabled so lanes-studio-host-enrol.timer retries).
7
+ # Never prints a key. --dry-run changes nothing. --help lists the options.
8
+ set -eu
9
+ repo=$(cd "$(dirname "$0")/.." && pwd)
10
+ case "$(uname -s)" in Linux) ;; *) echo 'The co-located Studio Host runs on a Linux lanes server.' >&2; exit 1 ;; esac
11
+ [ "$(id -u)" != 0 ] || { echo 'Run as the workspace account, not root.' >&2; exit 1; }
12
+ command -v python3 >/dev/null 2>&1 || { echo 'Missing prerequisite: python3 (see docs/INSTALL.md)' >&2; exit 1; }
13
+ export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
14
+ exec python3 "$repo/libexec/studio_host.py" enable "$@"
@@ -22,7 +22,7 @@ for command in python3 curl tar sha256sum infocmp tic install systemctl docker t
22
22
  command -v "$command" >/dev/null 2>&1 || { echo "Missing prerequisite: $command (see docs/INSTALL.md)" >&2; exit 1; }
23
23
  done
24
24
  python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 9) else "Python 3.9+ is required")'
25
- export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
25
+ export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
26
26
  if [ "$start" = 1 ]; then
27
27
  systemctl --user show-environment >/dev/null
28
28
  python3 -c 'import subprocess; subprocess.run(["docker", "info", "--format", "{{.ServerVersion}}"], check=True, timeout=15)'
@@ -37,8 +37,11 @@ mkdir -p "$share/bin" "$share/dl" "$state/flags"
37
37
  # The view server's PATH (bin/lanes clean_env): the Run pane that renders `lanes preview` sees this, never the
38
38
  # installer's PATH, so a glow in ~/.local/bin or ~/go/bin does not count as installed.
39
39
  pane_path=$share/bin:$repo/bin:/usr/local/bin:/usr/bin:/bin
40
- grep -v '^#' "$repo/holders.lock" | while read -r name version sha url; do
40
+ grep -v '^#' "$repo/holders.lock" | while read -r name version sha url flag _rest; do
41
41
  [ -n "$name" ] || continue
42
+ # An `opt-in` pin (the co-located Studio Host's throttle-rc) is installed by the opt-in step that needs it
43
+ # (install/enable-studio-host.sh, `lanes studio-host install`), never by every server install.
44
+ [ "$flag" != opt-in ] || continue
42
45
  target=$share/bin/$name-$version
43
46
  if [ -x "$target" ]; then echo "ok $name $version"; continue; fi
44
47
  # The markdown preview's renderer is optional: a glow the pane can find serves `lanes preview` as well.
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env python3
2
2
  """Register this checkout's user services without starting or restarting them."""
3
+ import json
3
4
  import os
4
5
  import shutil
6
+ import sys
7
+ import subprocess
5
8
  from pathlib import Path
6
9
  import tempfile
7
10
 
@@ -35,8 +38,95 @@ def notify_settings(environ):
35
38
  return {name: environ[name] for name in NOTIFY_SETTINGS if environ.get(name)}
36
39
 
37
40
 
38
- def register(repo, home, share, state, workspaces=None, economy=None, notify=None):
41
+ def throttle_rc_path(share):
42
+ """The co-located Studio Host's binary: a stable link the opt-in installer points at a sha256-verified file
43
+ (`lanes studio-host install`, libexec/studio_host.py). Rendered whether or not it exists yet, like @CADDY@."""
44
+ return Path(share) / 'bin' / 'throttle-rc'
45
+
46
+
47
+ # Units that run throttle-rc (or the installer that resolves its paths): they must see the SAME XDG roots the
48
+ # installer used, or the config and credential it wrote are not where the supervised Host looks (review round 1).
49
+ # Review round 3: BOTH roots are pinned ALWAYS — the installer's own, else the defaults as systemd specifiers — never
50
+ # left to the user manager's environment block, which can carry roots the installing shell did not.
51
+ STUDIO_HOST_UNITS = ('lanes-studio-host.service', 'lanes-studio-host-enrol.service')
52
+ XDG_ROOTS = ('XDG_CONFIG_HOME', 'XDG_DATA_HOME')
53
+ XDG_DEFAULTS = {'XDG_CONFIG_HOME': '%h/.config', 'XDG_DATA_HOME': '%h/.local/share'}
54
+
55
+
56
+ # Review round 4: the roots the opt-in enable step (install/enable-studio-host.sh) rendered the studio units with,
57
+ # recorded beside its enrolment record. A later server install (re-run for every update, possibly from a shell that
58
+ # does not carry them, e.g. `sudo -u <account> sh` without -i) renders the studio units from THIS record, never from
59
+ # its own environment, so it cannot move the supervised Host away from the config and credential enable wrote.
60
+ XDG_RECORD = Path('studio-host') / 'xdg-roots.json'
61
+
62
+
63
+ def recorded_xdg(state):
64
+ """The roots enable recorded under $LANES_STATE, or None when it never ran (or the record is unreadable)."""
65
+ try:
66
+ doc = json.loads((Path(state) / XDG_RECORD).read_text())
67
+ except (OSError, ValueError):
68
+ return None
69
+ if not isinstance(doc, dict) or not isinstance(doc.get('roots'), dict):
70
+ return None
71
+ return {name: value for name, value in doc['roots'].items()
72
+ if name in XDG_ROOTS and isinstance(value, str) and value.startswith('/')}
73
+
74
+
75
+ def record_xdg(state, xdg):
76
+ """Record the roots enable renders the studio units with (an empty dict = the defaults)."""
77
+ path = Path(state) / XDG_RECORD
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ fd, temporary = tempfile.mkstemp(prefix='.xdg-roots-', dir=path.parent)
80
+ try:
81
+ with os.fdopen(fd, 'w') as handle:
82
+ json.dump({'schema': 'lanes.studio-host-xdg-roots', 'version': 1,
83
+ 'roots': {name: str(Path(xdg[name]).resolve()) for name in XDG_ROOTS if xdg.get(name)}},
84
+ handle, sort_keys=True)
85
+ handle.write('\n')
86
+ os.chmod(temporary, 0o600)
87
+ os.replace(temporary, path)
88
+ finally:
89
+ if os.path.exists(temporary):
90
+ os.unlink(temporary)
91
+
92
+
93
+ def studio_host_config(home, xdg):
94
+ """The Host's config path as libexec/studio_host.py's Layout resolves it: $XDG_CONFIG_HOME, else %h/.config
95
+ (left as the systemd specifier so the unit stays the golden when no XDG root is set)."""
96
+ if xdg.get('XDG_CONFIG_HOME'):
97
+ return unit_value(Path(xdg['XDG_CONFIG_HOME']) / 'throttle-rc/studio-host.toml', command=True)
98
+ return '%h/.config/throttle-rc/studio-host.toml'
99
+
100
+
101
+ def manager_unit_dir(home):
102
+ """Where the user MANAGER reads unit files (systemd.unit(5)): $XDG_CONFIG_HOME/systemd/user when the manager's own
103
+ environment sets it, else ~/.config/systemd/user. Asked of the manager, never of this shell (review round 2);
104
+ no answer keeps the default. libexec/studio_host.py's manager_units_dir is the same rule."""
105
+ default = Path(home) / '.config/systemd/user'
106
+ try:
107
+ result = subprocess.run(['systemctl', '--user', 'show-environment'], capture_output=True, text=True,
108
+ timeout=10, stdin=subprocess.DEVNULL)
109
+ except (OSError, subprocess.TimeoutExpired):
110
+ return default
111
+ if result.returncode == 0:
112
+ for line in result.stdout.splitlines():
113
+ if line.startswith('XDG_CONFIG_HOME=') and line.split('=', 1)[1].startswith('/'):
114
+ return Path(line.split('=', 1)[1]) / 'systemd/user'
115
+ return default
116
+
117
+
118
+ def register(repo, home, share, state, workspaces=None, economy=None, notify=None, xdg=None, units_dir=None):
39
119
  repo, home, share, state = (Path(p).resolve() for p in (repo, home, share, state))
120
+ if xdg is None:
121
+ ambient = {name: os.environ[name] for name in XDG_ROOTS if os.environ.get(name)}
122
+ xdg = recorded_xdg(state)
123
+ if xdg is None:
124
+ xdg = ambient
125
+ elif {k: str(Path(v).resolve()) for k, v in ambient.items()} != {k: str(Path(v).resolve()) for k, v in xdg.items()}:
126
+ print(f'services.py: the studio units keep the XDG roots install/enable-studio-host.sh recorded in '
127
+ f'{state / XDG_RECORD} ({xdg or "the defaults"}), not this shell\'s ({ambient or "none"}); re-run '
128
+ 'install/enable-studio-host.sh from the shell whose roots should hold', file=sys.stderr)
129
+ xdg = {name: str(Path(value).resolve()) for name, value in xdg.items() if name in XDG_ROOTS and value}
40
130
  current = home / '.local/share/lanes/current'
41
131
  links = ((current, repo), (home / '.local/bin/lanes', repo / 'bin/lanes'))
42
132
  for link, _ in links:
@@ -50,6 +140,9 @@ def register(repo, home, share, state, workspaces=None, economy=None, notify=Non
50
140
  for name, value in sorted((economy or {}).items()) if name in ECONOMY_SETTINGS)
51
141
  notify_environment = ''.join(f'Environment="{name}={unit_value(value)}"\n'
52
142
  for name, value in sorted((notify or {}).items()) if name in NOTIFY_SETTINGS)
143
+ studio_environment = environment + ''.join(
144
+ f'Environment="{name}={unit_value(xdg[name]) if name in xdg else XDG_DEFAULTS[name]}"\n'
145
+ for name in XDG_ROOTS)
53
146
  rendered = {}
54
147
  for source in sorted((repo / 'systemd').iterdir()):
55
148
  if source.name.startswith('.') or source.suffix not in ('.service', '.timer', '.slice') or not source.is_file():
@@ -57,12 +150,15 @@ def register(repo, home, share, state, workspaces=None, economy=None, notify=Non
57
150
  content = source.read_text().replace('@REPO@', unit_value(repo, command=True))
58
151
  content = content.replace('@SHARE@', unit_value(share, command=True))
59
152
  content = content.replace('@CADDY@', unit_value(shutil.which('caddy') or '/usr/bin/caddy', command=True))
153
+ content = content.replace('@THROTTLE_RC@', unit_value(throttle_rc_path(share), command=True))
154
+ content = content.replace('@STUDIO_HOST_CONFIG@', studio_host_config(home, xdg))
60
155
  if '[Service]\n' in content:
156
+ lines = studio_environment if source.name in STUDIO_HOST_UNITS else environment
61
157
  extra = (economy_environment if source.name in ECONOMY_UNITS else
62
158
  notify_environment if source.name in NOTIFY_UNITS else '')
63
- content = content.replace('[Service]\n', '[Service]\n' + environment + extra, 1)
159
+ content = content.replace('[Service]\n', '[Service]\n' + lines + extra, 1)
64
160
  rendered[source.name] = '# Generated by lanes/install/services.py; re-run the server installer to update.\n' + content
65
- units = home / '.config/systemd/user'
161
+ units = Path(units_dir) if units_dir else home / '.config/systemd/user'
66
162
  units.mkdir(parents=True, exist_ok=True)
67
163
  for name, content in rendered.items():
68
164
  fd, temporary = tempfile.mkstemp(prefix='.lanes-', dir=units)
@@ -85,4 +181,5 @@ if __name__ == '__main__':
85
181
  register(Path(__file__).resolve().parents[1], Path.home(),
86
182
  os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'),
87
183
  os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'),
88
- os.environ.get('LANES_WORKSPACES'), economy_settings(os.environ), notify_settings(os.environ))
184
+ os.environ.get('LANES_WORKSPACES'), economy_settings(os.environ), notify_settings(os.environ),
185
+ units_dir=manager_unit_dir(Path.home()))
@@ -285,7 +285,7 @@ EOF
285
285
  # parent's only include) and nothing loads later. Omarchy's theme file sits two deep: one hop.
286
286
  # 2. KIND — a theme pair loses to ANY explicit colour key in the everyday chain (above), so the pair is installed only
287
287
  # when that chain sets none; otherwise the explicit dark palette (last assignment wins) and `say` names the file.
288
- # Both rules are read from Ghostty's source, not observed on a running Ghostty here (DEF-1466 holds the live check).
288
+ # Both rules are read from Ghostty's source, not observed on a running Ghostty here (DEF-1547 holds the live check).
289
289
  # Without the flag the include is absent and every earlier palette file is removed: inherit-first is the default.
290
290
  install_palette() {
291
291
  rm -f "$config"/ghostty-palette*.conf
@@ -194,7 +194,7 @@ def read_sidecar_dict(d, now, max_age, proc_root=None):
194
194
 
195
195
  Freshness is the LAST event (`at`; `since` when a writer predates `at`), while `since` is when the status began:
196
196
  a `working` stretch re-signalled by every tool call keeps its start. lane-run's `{phase, exit, at, pid}` records
197
- live one directory up and are never a sidecar (no `v`; the two writers never share a file name, DEF-1436). With
197
+ live one directory up and are never a sidecar (no `v`; the two writers never share a file name, DEF-1572). With
198
198
  `proc_root` (the container's /proc as the host sees it) a recorded `pid` must be live and, when the writer
199
199
  recorded `start`, still be the same process (field 22 of /proc/<pid>/stat, as `claude_sessions` checks Claude's
200
200
  `procStart`): a crashed agent's last word is never published. `pid` 0 means the writer could not name its agent;
@@ -21,7 +21,7 @@ NEW lane its identity (LANE_ID, LANE_WS, the lanes/<ws>/<id> trace). `--identity
21
21
  recipe of the plain shape `[exec] tmux -L <socket> new -ADs <session> [-c <dir>] <command…>` the same identity
22
22
  (`plain_tmux_new`: the command argv is kept, only the `-e` flags are added); any other working recipe is left
23
23
  alone and its lane carries no identity. The old value is kept as `createPrevious` in both cases. Identity reaches a
24
- tmux lane ONLY through these `-e` flags: `lane open` never exports it (DEF-1478, lanes/docs/ARCHITECTURE.md).
24
+ tmux lane ONLY through these `-e` flags: `lane open` never exports it (DEF-1559, lanes/docs/ARCHITECTURE.md).
25
25
  Boards whose registry lives outside a repository are additionally PROJECTED into the file Cursor
26
26
  reads for that folder, named by `"cursor": "<path>"` on the repository entry of the workspace manifest.
27
27
  """
@@ -83,7 +83,7 @@ def needs_canonical(create, folder, exists):
83
83
  return 'no create recipe'
84
84
  recipe = create.replace('${workspaceFolder}', folder).replace('[workspaceFolder]', folder)
85
85
  # Every class `lanes doctor` warns about (lanes_lib.classify_recipe: null, attach-only, retired-path) is one this
86
- # repairs, read by the same parser, so the doctor's "fix with --canonicalize" is never a no-op (DEF-1460).
86
+ # repairs, read by the same parser, so the doctor's "fix with --canonicalize" is never a no-op (DEF-1541).
87
87
  reason = CLASS_REASONS.get(L.classify_recipe(recipe))
88
88
  if reason:
89
89
  return reason
@@ -28,7 +28,7 @@ The poller (plan Task 11, opt-in `lanes-economy.service`; enabling it is an oper
28
28
  production daemon's loopback control port): every `--interval` seconds it GETs `/status` and `/sessions` (3 s
29
29
  timeout, optional bearer from `LANES_THROTTLE_TOKEN_FILE`), folds today's log through `day_totals`, and writes
30
30
  `STATE/economy.json` (schema `v: 1`, additive) only when the payload changed apart from `at` — or when the file's
31
- `at` is HEARTBEAT seconds old, so `at` is a liveness heartbeat the sidebar ages with its own clock (DEF-1447); the
31
+ `at` is HEARTBEAT seconds old, so `at` is a liveness heartbeat the sidebar ages with its own clock (DEF-1528); the
32
32
  cursor lives in `STATE/economy.cursor.json`. `LANES_THROTTLE_CONTROL=none` disables every daemon request (the log fold still runs);
33
33
  `--status-file`/`--sessions-file` feed fixture documents instead. A failed `/status` keeps the previous `fleet` with
34
34
  `stale: true`; a failed `/sessions` keeps each lane's previous live figures with `stale: true`. GET requests only:
@@ -38,7 +38,7 @@ Where it runs: `lanes-economy.service` is a HOST user unit, while the daemon (it
38
38
  workspace container. `LANES_THROTTLE_CONTAINER=<name>` reads the log through that container's home volume
39
39
  (`lanes_lib.host_path` of `/home/coder/.local/share/throttle`, the home recorded in `STATE/containers.json` by
40
40
  `lanes sync`); an explicit `LANES_THROTTLE_LOG_DIR` wins. The control URL must be reachable from the HOST's network
41
- namespace — a daemon bound to the container's 127.0.0.1 is not (DEF-1444). Why a source is down (`connection
41
+ namespace — a daemon bound to the container's 127.0.0.1 is not (DEF-1525). Why a source is down (`connection
42
42
  refused`, `timed out`, `HTTP 401`, `log dir missing`, ...) goes to stderr — the journal — each time it changes, and
43
43
  on every `--once`; it is never written into economy.json.
44
44
  """
@@ -572,7 +572,7 @@ def poll_once(state_dir, env, opener=None, now=None, write=True, status_file=Non
572
572
  diagnostics=None):
573
573
  """One poll: the daemon (or fixture files), today's log fold and the lane maps → the economy.json v1 payload.
574
574
  With `write`, economy.json is replaced only when the payload changed apart from `at` or its `at` is HEARTBEAT
575
- seconds old (the liveness heartbeat, DEF-1447), and the cursor only when it changed. Never raises for a down daemon or a missing log; raises LanesError for a malformed control URL. A
575
+ seconds old (the liveness heartbeat, DEF-1528), and the cursor only when it changed. Never raises for a down daemon or a missing log; raises LanesError for a malformed control URL. A
576
576
  `diagnostics` dict receives why each of status, sessions and log is unavailable (None when it answered)."""
577
577
  state_dir = Path(state_dir)
578
578
  now = int(time.time() if now is None else now)
@@ -799,7 +799,7 @@ def classify_recipe(create):
799
799
 
800
800
  def recipe_identity(create):
801
801
  """Does the recipe give the lane it creates its identity — an `-e LANE_ID=…` on a tmux new-session (the
802
- canonical recipe's flags, DEF-1478)? An env prefix (`LANE_ID=x tmux …`) does not count: it reaches the tmux
802
+ canonical recipe's flags, DEF-1559)? An env prefix (`LANE_ID=x tmux …`) does not count: it reaches the tmux
803
803
  client, never the session."""
804
804
  lists = _recipe_word_lists(create) if isinstance(create, str) else None
805
805
  return any(verb in _RECIPE_NEW and any(letter == 'e' and value.startswith('LANE_ID=')
@@ -16,9 +16,9 @@ NEEDS_YOU set. The six-word `state` is never read: the agent status is the only
16
16
  lane) for a DESK device only: one with a `<device>.*.pid` of any age whose `STATE/devices/<device>.json` is not
17
17
  `mobile: true`. Phone presence is decided by the device's mobile layout, not by how it attached: a Safari phone
18
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.
19
+ from the phone's picker never mutes pushes (DEF-1533). A phone's own attach within the window still counts.
20
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.
21
+ looking now" (DEF-1532). A suppressed transition is consumed, never sent later.
22
22
  * Payload: `<lane name>: <agent task> — <status>` and, on a second line, the client command that opens the lane
23
23
  (`lanes-launch lane <id>`). No client registers a URL scheme for it, so the push carries a tappable action only
24
24
  when `action_url` (a template over `{lane}`) is configured.
@@ -140,7 +140,7 @@ def device_active(clients_dir, window, now, devices_dir=None):
140
140
  (the mark of `lanes attach`) AND whose `devices_dir/<device>.json` is not `mobile: true`. The mobile flag is
141
141
  the authoritative phone mark: a Safari phone never runs `lanes attach`, but an SSH-only phone may
142
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`
143
+ never mutes the phone's own pushes, however it attached (DEF-1533). `devices_dir` defaults to the `devices`
144
144
  directory beside `clients_dir` (both live in STATE). A missing directory is nobody present; a window of 0 is
145
145
  never present."""
146
146
  if window <= 0:
@@ -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-1475)."""
84
+ UNREGISTERED session is renamed through are never read for a registered one (DEF-1556)."""
85
85
  name = L._title(new_name)
86
86
  with L.registry_lock():
87
87
  data = load()
@@ -179,7 +179,7 @@ def promote_to_registered(workspace_id, entry, registry):
179
179
  raise L.LanesError('Only tmux sessions can be promoted (zmx and exec holders are not supported)')
180
180
  # A binding any board already holds — a repository declaration or an adopted registered session — is
181
181
  # registered. Appending it again would make the repository declaration win the binding on the next sync,
182
- # re-keying an adopted lane's id and orphaning its view targets (DEF-1474). Nothing is written.
182
+ # re-keying an adopted lane's id and orphaning its view targets (DEF-1555). Nothing is written.
183
183
  if entry.get('registered_session') or session_key(entry) in {
184
184
  session_key(lane) for w in registry.get('workspaces', []) for lane in w.get('lanes', [])
185
185
  if lane.get('holder') == 'tmux'}:
@@ -93,7 +93,7 @@ def osc_text(text):
93
93
  # ----------------------------------------------------------------- economy (plan Task 12, spec §4.2/§4.6(c))
94
94
  ECONOMY_SCHEMA = 1 # economy.json `v` this reader understands (Task 11, DM-LANES-ECONOMY; additive)
95
95
  STARVING_AGE = 600 # seconds without a request while the pane is producing output
96
- # economy.json older than this by its own `at`, measured with OUR clock, is a frozen picture (DEF-1447): the poller
96
+ # economy.json older than this by its own `at`, measured with OUR clock, is a frozen picture (DEF-1528): the poller
97
97
  # rewrites the file at least every economy_poll.HEARTBEAT (60 s) even when nothing changed, so three missed heartbeats
98
98
  # mean the poller stopped (crashed, disabled, or a host too loaded to run it) — the same own-clock rule as HEALTH_STALE.
99
99
  ECONOMY_STALE = 180
@@ -564,7 +564,7 @@ class Sidebar:
564
564
 
565
565
  def economy_aged(self, now):
566
566
  """True when the loaded economy.json is older than ECONOMY_STALE by its own `at` against `now` (our clock):
567
- the poller stopped, so every figure in it is frozen (DEF-1447). False without a document or without a numeric
567
+ the poller stopped, so every figure in it is frozen (DEF-1528). False without a document or without a numeric
568
568
  `at` — nothing is known, so nothing is claimed (health_banner's rule for a missing health file)."""
569
569
  at = number(self.economy.get('at')) if self.economy else None
570
570
  return at is not None and now - at > ECONOMY_STALE
@@ -682,7 +682,7 @@ class Sidebar:
682
682
  marks the fleet `stale` (a frozen picture is not live), ink-dim with the stale mark in front."""
683
683
  fleet = self.economy.get('fleet') or {}
684
684
  # Frozen two ways: the poller kept its last fleet strip (`/status` down), or the poller itself stopped and the
685
- # whole document is older than ECONOMY_STALE by our clock (DEF-1447). Both are painted alike.
685
+ # whole document is older than ECONOMY_STALE by our clock (DEF-1528). Both are painted alike.
686
686
  stale = fleet.get('stale') is True or self.economy_aged(now)
687
687
  spend, budget = number(fleet.get('overflow_spend_today_usd')), number(fleet.get('overflow_budget_usd'))
688
688
  fraction = number(fleet.get('spent_fraction'))
@@ -766,7 +766,7 @@ class Sidebar:
766
766
  """(glyph, role) for column 34, by priority (spec §4.2): not-observed beats everything, then what is
767
767
  actionable — stale `?` > starving ⚠ > downgraded ↓ > paid `$` > a blank. `econ` is the lane's economy.json
768
768
  object from `self.economy` or None (no poller, or a lane the poller has not joined: the tail is blank).
769
- A FROZEN economy figure keeps its glyph but loses the attention tone (ink-dim, DEF-1447): the lane record
769
+ A FROZEN economy figure keeps its glyph but loses the attention tone (ink-dim, DEF-1528): the lane record
770
770
  carries `stale: true` (the poller kept it while `/sessions` was down), or — with `now` — the whole document
771
771
  is older than ECONOMY_STALE. A frozen ⚠ is last-known, not a live alarm; the `?` stays statusd's mark."""
772
772
  if observed.get('stale'):
@@ -906,7 +906,7 @@ class Sidebar:
906
906
  """The cursor is a LANE (spec §4.5). `entries` is a freshly computed list, `cursor_id` the lane the cursor was
907
907
  on before it: the index is re-found from the id, and `top` shifts by the rows that appeared above it, so a lane
908
908
  that moves between sections never moves the operator's eye. Then the index is clamped. render() and
909
- render_mobile() bound the shifted `top` by the list (DEF-1433): a list that fits the body is never scrolled."""
909
+ render_mobile() bound the shifted `top` by the list (DEF-1514): a list that fits the body is never scrolled."""
910
910
  if cursor_id:
911
911
  index = next((i for i, e in enumerate(entries) if e.get('id') == cursor_id), None)
912
912
  if index is not None and index != self.cursor:
@@ -1107,7 +1107,7 @@ class Sidebar:
1107
1107
  return
1108
1108
  try:
1109
1109
  # Routed by the registry SHAPE, never by the id's spelling: an adopted session keeps its `--x-` id
1110
- # but its name authority is registered-sessions.json, not user-sessions/ (DEF-1475).
1110
+ # but its name authority is registered-sessions.json, not user-sessions/ (DEF-1556).
1111
1111
  if entry.get('registered_session'):
1112
1112
  R.rename(entry['id'], new_name)
1113
1113
  self.message = f'Renamed → {new_name}'
@@ -1142,7 +1142,7 @@ class Sidebar:
1142
1142
  return
1143
1143
  # Gated the way `start_registration` is: only an UNREGISTERED, reconnectable tmux session. A repository
1144
1144
  # lane or an adopted (`registered_session`) lane is already registered; promoting it would append a
1145
- # second declaration of the same binding to the repository's terminals.json (DEF-1474).
1145
+ # second declaration of the same binding to the repository's terminals.json (DEF-1555).
1146
1146
  if entry.get('registered_session') or entry.get('repo') or entry.get('source'):
1147
1147
  self.message = 'Already a registered lane; nothing to promote'
1148
1148
  self.dirty = True
@@ -276,7 +276,7 @@ def agent_signals(containers, sessions_by_server):
276
276
  (`parse_codex_title`) over a bare-shell foreground can be a harness that ended into its shell, and only the
277
277
  child read (`child_liveness`) tells that apart from a live harness launched under `bash -c` or a wrapper. A pane
278
278
  a live Claude session file already claims is not probed (the file outranks the title). The key is set only for
279
- a known answer: unknown liveness leaves it absent, and an absent key never demotes a title (DEF-1476). A
279
+ a known answer: unknown liveness leaves it absent, and an absent key never demotes a title (DEF-1557). A
280
280
  container whose files cannot be read costs only its own agents, never a lane its `state`."""
281
281
  claude = {}
282
282
  for container in sorted({container for container, _socket in sessions_by_server}):
@@ -312,7 +312,7 @@ def agent_signals(containers, sessions_by_server):
312
312
  SIDECAR_MAX_AGE = 3600 # a hook sidecar whose last event is older than this loses to the live signals
313
313
  # The hook sidecars' own directory under the container home. NOT `.lz/state` itself: lane-run writes each zmx lane's
314
314
  # `{phase, ...}` record there as `<zmx name>.json` and exports that name as LANE_ID, so a shared name would let a hook
315
- # overwrite the record `observe_zmx_container` and the container `lane ls` read (DEF-1436).
315
+ # overwrite the record `observe_zmx_container` and the container `lane ls` read (DEF-1572).
316
316
  SIDECAR_DIR = ('.lz', 'state', 'hooks')
317
317
 
318
318
 
@@ -337,7 +337,7 @@ def agent_sidecars(containers, wanted, now=None):
337
337
  ~/.lz/state/hooks per cycle, and a read only for the `.json` names in it — lane-run's phase records in the parent
338
338
  directory are never listed or read here. A container whose directory cannot be listed simply has no sidecars —
339
339
  absent is "no hook installed", never an error a lane pays for. A zmx lane's hook sidecar is named by its zmx
340
- session (lane-run's LANE_ID), not by its published `<ws>--z-<name>` id, so it joins no lane yet (DEF-1437)."""
340
+ session (lane-run's LANE_ID), not by its published `<ws>--z-<name>` id, so it joins no lane yet (DEF-1573)."""
341
341
  out = {}
342
342
  for container in sorted(wanted):
343
343
  info = containers.get(container) or {}
@@ -757,7 +757,7 @@ def observe_zmx_container(container):
757
757
  name = fields.get('name', '')
758
758
  if not L.IDENT.match(name):
759
759
  continue
760
- # The meta and state sidecars are written INSIDE the container (DEF-1461): each one is read as an object or
760
+ # The meta and state sidecars are written INSIDE the container (DEF-1542): each one is read as an object or
761
761
  # nothing, each field as one printable line or nothing, and the workspace must be a lanes identifier — it
762
762
  # names a host file below. A malformed sidecar costs its own row's labels, never the container's other rows.
763
763
  # `lane new` writes exactly these shapes (`ws` checked against IDENT or empty, `zmx_version` a string).
@@ -782,12 +782,22 @@ def observe_zmx_container(container):
782
782
  return lanes, groups
783
783
 
784
784
 
785
- def observe_zmx(containers, previous=None):
786
- """zmx-held lanes describe themselves (labels + meta sidecar): no registry edit is needed to see one."""
785
+ def observe_zmx(containers, previous=None, listed=None):
786
+ """zmx-held lanes describe themselves (labels + meta sidecar): no registry edit is needed to see one.
787
+
788
+ `listed`, when given, receives `{container: unix time the zmx list was ISSUED}` for every container whose listing
789
+ ANSWERED this cycle (status.json `zmx_listed_at`). A container that timed out or failed is absent from it: its
790
+ groups are the previous ones republished, which say nothing about a lane created since. The co-located Studio
791
+ Host prunes a lane it started only on such a listing (design §8.10 C1, review round 6 — it read absence from any
792
+ fresh status.json and lost stop authority over lanes a timed-out listing had never seen)."""
787
793
  lanes, groups = {}, {}
788
794
  for container in containers:
795
+ issued = time.time()
789
796
  try:
790
- found = observe_zmx_container(container) or kept_zmx(previous or {}, 'timeout', container)
797
+ answered = observe_zmx_container(container)
798
+ found = answered or kept_zmx(previous or {}, 'timeout', container)
799
+ if answered is not None and listed is not None:
800
+ listed[container] = issued
791
801
  except Exception as error:
792
802
  log(f'zmx {container}', error)
793
803
  found = kept_zmx(previous or {}, 'error', container)
@@ -860,7 +870,7 @@ def build_payload(ctx, level, reconcile=True):
860
870
  except Exception as error:
861
871
  log('observe', error)
862
872
  lanes = {lane_id: keep(previous, lane_id, 'error') for lane_id in previous.get('lanes') or {}}
863
- # DEF-1457: COPIES of what was published, never the previous payload itself (`previous` is the very object
873
+ # DEF-1538: COPIES of what was published, never the previous payload itself (`previous` is the very object
864
874
  # `ctx['published']` holds, so a kept list extended in place would grow the published payload), and only
865
875
  # observe's own rows (holder 'tmux', the rows `observe` carries over too).
866
876
  servers = dict(previous.get('servers') or {})
@@ -876,11 +886,15 @@ def build_payload(ctx, level, reconcile=True):
876
886
  reconcile_client_sizes(registry, containers, live)
877
887
  except Exception as error:
878
888
  log('reconcile', error)
889
+ listed = {}
879
890
  try:
880
- zmx_lanes, zmx_groups = observe_zmx(containers, previous)
891
+ zmx_lanes, zmx_groups = observe_zmx(containers, previous, listed)
881
892
  except Exception as error:
882
893
  log('zmx', error)
883
894
  zmx_lanes, zmx_groups = kept_zmx(previous, 'error')
895
+ listed = {}
896
+ # Outside the payload: it changes every cycle, and status.json is rewritten only on change (plus the heartbeat).
897
+ ctx['zmx_listed_at'] = listed
884
898
  lanes.update(zmx_lanes)
885
899
  return {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
886
900
  'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
@@ -919,7 +933,8 @@ def cycle(ctx, reconcile=True):
919
933
  if payload != ctx['published'] or started - ctx['status_at'] > HEARTBEAT:
920
934
  try:
921
935
  written = time.time()
922
- L.atomic_write(L.STATE / 'status.json', json.dumps(dict(payload, health=health, updated_at=written)) + '\n')
936
+ L.atomic_write(L.STATE / 'status.json', json.dumps(dict(payload, health=health, updated_at=written,
937
+ zmx_listed_at=ctx.get('zmx_listed_at') or {})) + '\n')
923
938
  ctx['published'], ctx['status_at'] = payload, written
924
939
  except Exception as error:
925
940
  log('status.json', error)