@vibelord/lanes 1.1.1 → 1.3.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 (71) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. package/payload/manifest.json +6 -3
@@ -11,7 +11,9 @@ a `holder: tmux` lane through its exact socket and session; any other id is a zm
11
11
  lane; if it does not exist, run its recorded create recipe. Never nests inside tmux.
12
12
  While the lanes view shows the lane, attach with ignore-size (the lanes view owns the size).
13
13
  lane attach <id> attach this terminal (never creates; a tmux lane never nests inside tmux)
14
- lane send <id> [--no-enter] <text...> type into the lane. tmux: literally, then Enter unless --no-enter.
14
+ lane send <id> [--no-enter] <text...> type into the lane. tmux: literally, then Enter unless --no-enter
15
+ (a second tmux client half a second later: an agent's prompt takes text+Enter in one
16
+ burst as a paste)
15
17
  zmx: the bytes as given, never an Enter of its own (use $'\\r')
16
18
  lane capture <id> [--lines N] [--vt] print scrollback (plain text, or VT with --vt)
17
19
  lane ls [--json] [--all] zmx lanes with holder metadata; --all adds the registry's tmux lanes with `alive`
@@ -21,6 +23,22 @@ a `holder: tmux` lane through its exact socket and session; any other id is a zm
21
23
  hook sidecar says the agent is `working` unless --force
22
24
  lane gc --sockets unlink this container's tmux socket files that tmux is refused on (ECONNREFUSED: no
23
25
  server listens); prints each unlinked path. A live, empty or silent server keeps its file
26
+ lane publish <file> [--title T] [--for <login>] [--kind md|html|svg|file]
27
+ put the file on the Lanes docs page from the lane this runs in ($LANE_ID, else the tmux
28
+ pane): writes ~/.lz/state/publish/<lane>/<id>.json (the file's container path, hashed
29
+ once) and prints {id, …, picked_up_by, expected_within_s} as JSON; the host's lanes-docsd
30
+ takes it within ~5 s and writes <id>.receipt.json beside it (kind from the extension when
31
+ --kind is left out; the file must be under /home/coder, at most 32 MiB)
32
+ lane mcp install [--agent claude|codex|all] [--write]
33
+ add the Lanes MCP server (~/.lz/bin/lane-mcp) to ~/.claude.json (mcpServers.lanes) and
34
+ ~/.codex/config.toml ([mcp_servers.lanes], with env_vars passing LANE_ID, LANE_WS, TMUX
35
+ and TMUX_PANE: Codex clears a server's environment): prints the diff; --write applies
36
+ it, keeping <file>.bak-<UTC> first; an entry that is already there is left alone (a
37
+ Codex table missing those env_vars gains them), one named `lanes` that starts something
38
+ else is never replaced (exit 1). New agent sessions load it
39
+
40
+ Every tmux client these verbs start runs with -N (never starts a server), without a lane identity in its
41
+ environment and bounded in time (lane_core.py, shipped beside this file).
24
42
  """
25
43
  import json
26
44
  import os
@@ -34,6 +52,10 @@ import sys
34
52
  import time
35
53
  from pathlib import Path
36
54
 
55
+ sys.dont_write_bytecode = True # ~/.lz/bin holds no __pycache__
56
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
57
+ import lane_core as C # noqa: E402 (shipped beside this file by `lanes install-container`)
58
+
37
59
  ROOT = Path(os.environ.get('LANE_ROOT', '/home/coder/.lz'))
38
60
  VERSION = os.environ.get('LANE_ZMX_VERSION', '0.8.1')
39
61
  ZMX = ROOT / 'bin' / f'zmx-{VERSION}'
@@ -41,8 +63,9 @@ SOCKETS, META, STATE = ROOT / 's', ROOT / 'meta', ROOT / 'state'
41
63
  HOOKS = STATE / 'hooks' # lane-signal's sidecars (never lane-run's phase records one directory up, DEF-1572)
42
64
  PROC = Path('/proc') # this container's pid namespace: the hook's agent pid is checked here
43
65
  IDENT = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$')
44
- STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle') # agent_state.STATUSES
45
- HOOK_MAX_AGE = 3600 # statusd.SIDECAR_MAX_AGE: an older last event is no agent
66
+ STATUSES = C.STATUSES # agent_state.STATUSES
67
+ HOOK_MAX_AGE = C.HOOK_MAX_AGE
68
+ TMUX_TIMEOUT = 15
46
69
 
47
70
 
48
71
  def zmx_env(extra=None):
@@ -161,49 +184,32 @@ def registry_entry(lane):
161
184
  return lanes[lane]
162
185
 
163
186
 
164
- IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE') # + a lanes/ ANTHROPIC_CUSTOM_HEADERS (identity_free)
165
-
166
-
167
- def identity_free(environ, drop=('TMUX',)):
168
- """`environ` without any lane identity (lanes/docs/ARCHITECTURE.md "Lane identity"). Whatever tmux client STARTS
169
- a socket's server hands its whole environment to the server's GLOBAL environment, and every later session on
170
- that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1559). Board sockets are shared by
171
- many lanes, so a lane identity in the env of anything that may start one would be stamped on unrelated lanes.
172
- Identity therefore reaches a lane's shell ONLY through `-e` in its create recipe (lanes_lib.canonical_recipe).
173
- An operator's own ANTHROPIC_CUSTOM_HEADERS that is not a lane trace is theirs and stays."""
174
- return {k: v for k, v in environ.items() if k not in drop and k not in IDENTITY
175
- and not (k == 'ANTHROPIC_CUSTOM_HEADERS' and v.startswith('x-throttle-trace: lanes/'))}
187
+ IDENTITY = C.IDENTITY # + a lanes/ ANTHROPIC_CUSTOM_HEADERS (identity_free)
188
+ # `environ` without any lane identity: whatever tmux client STARTS a socket's server hands its whole environment to
189
+ # the server's GLOBAL environment (MEASURED, tmux 3.4: DEF-1559), so identity reaches a lane's shell ONLY through `-e`
190
+ # in its create recipe (lanes_lib.canonical_recipe). lane_core.identity_free keeps an operator's own header.
191
+ identity_free = C.identity_free
192
+ tmux_target = C.tmux_target # (socket, session) of a registry entry held by tmux, else None
176
193
 
177
194
 
178
195
  def tmux_run(argv):
179
- """One tmux client, never carrying a lane identity (it could be the client that starts a shared board server)."""
180
- return subprocess.run(argv, capture_output=True, text=True, timeout=15, env=identity_free(os.environ))
196
+ """One tmux client (lane_core.run_tmux): no lane identity in its env, stdin closed, exit 124 after
197
+ TMUX_TIMEOUT s without an answer."""
198
+ return C.run_tmux(argv, timeout=TMUX_TIMEOUT)
181
199
 
182
200
 
183
201
  def tmux_cmd(socket, *args):
184
- return tmux_run(['tmux', '-L', socket, *args])
202
+ """`tmux -N -L <socket> ARGS…`: -N, so no query or attach ever starts a server (DEF-1797)."""
203
+ return tmux_run(C.tmux_argv(socket, *args))
185
204
 
186
205
 
187
206
  def registry_lanes(verb):
188
207
  """{lane id: entry} of ~/.lz/registry.json; {} when there is none (a zmx-only container needs none). A newer
189
208
  registry is refused, as `lane open` refuses it."""
190
209
  try:
191
- data = json.loads((ROOT / 'registry.json').read_text())
192
- lanes = data['lanes']
193
- except (OSError, ValueError, KeyError, TypeError):
194
- return {}
195
- if int(data.get('version', 1)) > 1:
210
+ return C.registry_lanes(ROOT)
211
+ except C.NewerVersion:
196
212
  sys.exit(f'lane {verb}: the registry is newer than this `lane` — on the host run: lanes install-container <container>')
197
- return lanes if isinstance(lanes, dict) else {}
198
-
199
-
200
- def tmux_target(entry):
201
- """(socket, session) of a registry entry held by tmux, else None."""
202
- if not isinstance(entry, dict) or entry.get('holder') != 'tmux':
203
- return None
204
- tmux = entry.get('tmux') or {}
205
- socket, session = tmux.get('socket'), tmux.get('session')
206
- return (str(socket), str(session)) if socket and session else None
207
213
 
208
214
 
209
215
  def tmux_lane(verb, lane):
@@ -220,33 +226,20 @@ def zmx_name(verb, lane):
220
226
  return name if isinstance(name, str) and IDENT.match(name) else lane
221
227
 
222
228
 
223
- def tmux_text(text):
224
- """tmux's argv parser ends a command at ONE trailing `;` of ANY argument and turns a trailing `\\;` into `;`
225
- (MEASURED, tmux 3.4: `send-keys -l -- 'echo a;'` types `echo a`). Writing that last `;` as `\\;` types the text
226
- exactly, a trailing `\\;` included."""
227
- return text[:-1] + '\\;' if text.endswith(';') else text
228
-
229
-
230
- def send_argv(socket, session, text, enter=True):
231
- """Type `text` literally into the session's active pane, then Enter — one tmux client, `;`-separated. The target
232
- is `=<session>:` (exact session, its current window): a bare `=<session>` is refused by send-keys and
233
- capture-pane with "can't find pane" (MEASURED, tmux 3.4)."""
234
- target = f'={session}:'
235
- argv = ['tmux', '-L', socket, 'send-keys', '-t', target, '-l', '--', tmux_text(text)]
236
- return argv + [';', 'send-keys', '-t', target, 'Enter'] if enter else argv
237
-
238
-
239
- def capture_argv(socket, session, lines, vt=False):
240
- """Joined scrollback of the session's active pane: the last `lines` history lines plus the screen (`-S -N`; the
241
- caller keeps the last N non-blank lines), or all of it (`-S -`); `--vt` keeps the escape sequences (`-e`)."""
242
- argv = ['tmux', '-L', socket, 'capture-pane', '-p', '-J', '-S', f'-{lines}' if lines else '-', '-t', f'={session}:']
243
- return argv + ['-e'] if vt else argv
229
+ # tmux's argv parser ends a command at ONE trailing `;` of ANY argument: the last `;` is written `\\;`. The target is
230
+ # `=<session>:` (exact session, its current window): a bare `=<session>` is refused by send-keys and capture-pane
231
+ # with "can't find pane" (MEASURED, tmux 3.4). Every argv carries -N. A text and its Enter are two clients
232
+ # ENTER_DELAY s apart (lane_core.send_keys): in one client they reach the pane as one read, which an agent's prompt
233
+ # takes as a paste (DEF-1803).
234
+ tmux_text, send_argvs, capture_argv = C.tmux_text, C.send_argvs, C.capture_argv
244
235
 
245
236
 
246
237
  def tmux_attach_argv(socket, session):
247
- """attach-session; with ignore-size while the lanes view shows the lane (the lanes view owns the size)."""
238
+ """attach-session; with ignore-size while the lanes view shows the lane (the lanes view owns the size). -N: when
239
+ the session's server ended after the has-session check, the attach fails instead of starting an empty server
240
+ that leaves a socket file behind (MEASURED, tmux 3.4: DEF-1797)."""
248
241
  clients = tmux_cmd(socket, 'list-clients', '-t', f'={session}', '-F', '#{client_termname}').stdout.split()
249
- argv = ['tmux', '-L', socket, 'attach-session']
242
+ argv = C.tmux_argv(socket, 'attach-session')
250
243
  if VIEWER_TERM in clients:
251
244
  argv += ['-f', 'ignore-size'] # the lanes view is showing this lane: never resize it from here
252
245
  return argv + ['-t', f'={session}']
@@ -254,34 +247,8 @@ def tmux_attach_argv(socket, session):
254
247
 
255
248
  def hook_agent(name):
256
249
  """The lane's `lane-signal` sidecar (~/.lz/state/hooks/<name>.json) as an agent dict, or {} — judged as the host's
257
- `agent_state.read_sidecar_dict` judges it: ours (`v` 1, a vocabulary word), a last event (`at`, else `since`)
258
- within HOOK_MAX_AGE, and a recorded agent pid still alive in this container (and the same process when the
259
- writer recorded its `start`). A crashed agent's last word is never an agent."""
260
- try:
261
- d = json.loads((HOOKS / f'{name}.json').read_text())
262
- except (OSError, ValueError):
263
- return {}
264
- if not isinstance(d, dict) or d.get('v') != 1 or d.get('status') not in STATUSES:
265
- return {}
266
- try:
267
- since = int(d.get('since') or 0)
268
- at = int(d.get('at') or since)
269
- pid = int(d.get('pid') or 0)
270
- except (TypeError, ValueError):
271
- return {}
272
- if since <= 0 or time.time() - at > HOOK_MAX_AGE:
273
- return {}
274
- if pid > 0:
275
- try:
276
- stat = (PROC / str(pid) / 'stat').read_text()
277
- except OSError:
278
- return {}
279
- if d.get('start') is not None:
280
- fields = stat.rsplit(')', 1)[-1].split()
281
- if len(fields) < 20 or fields[19] != str(d['start']):
282
- return {}
283
- return {'harness': str(d.get('harness') or ''), 'status': d['status'], 'since': since, 'at': at,
284
- 'event': str(d.get('event') or ''), 'session': str(d.get('session') or ''), 'pid': pid, 'source': 'hook'}
250
+ `agent_state.read_sidecar_dict` judges it (lane_core.hook_agent). A crashed agent's last word is never an agent."""
251
+ return C.hook_agent(HOOKS, name, PROC)
285
252
 
286
253
 
287
254
  def cmd_open(args):
@@ -343,19 +310,19 @@ def cmd_send(args):
343
310
  held = tmux_lane('send', name)
344
311
  if held:
345
312
  _, socket, session = held
346
- result = tmux_run(send_argv(socket, session, text, enter))
347
- else:
348
- result = zmx('send', zmx_name('send', name), text) # zmx: the bytes as given, never an Enter of its own
313
+ results = C.send_keys(socket, session, text, enter, run=tmux_run)
314
+ sys.stderr.write(''.join(result.stderr for result in results))
315
+ if len(results) == 2 and results[1].returncode:
316
+ sys.stderr.write('lane send: the text was typed; Enter was not pressed\n')
317
+ sys.exit(results[-1].returncode)
318
+ result = zmx('send', zmx_name('send', name), text) # zmx: the bytes as given, never an Enter of its own
349
319
  sys.stderr.write(result.stderr)
350
320
  sys.exit(result.returncode)
351
321
 
352
322
 
353
323
  def last_lines(text, lines):
354
324
  """The last `lines` lines that end in something: a tmux capture ends with the screen's unused blank rows."""
355
- rows = text.splitlines()
356
- while rows and not rows[-1].strip():
357
- rows.pop()
358
- return '\n'.join(rows[-lines:]) + '\n'
325
+ return C.last_lines(text, lines) or '\n'
359
326
 
360
327
 
361
328
  def cmd_capture(args):
@@ -560,8 +527,96 @@ def cmd_gc(args):
560
527
  print(f'lane gc --sockets: {removed} unlinked, {kept} kept in {directory}')
561
528
 
562
529
 
530
+ MCP_USAGE = 'usage: lane mcp install [--agent claude|codex|all] [--write]'
531
+
532
+
533
+ def cmd_mcp(args):
534
+ """`lane mcp install`: the agents' MCP entries for lane-mcp in THIS home (lane_core.mcp_plan / mcp_apply — the
535
+ code `lanes install-container` runs from the host). A dry run (the diffs) unless --write. Exit 1 when a file was
536
+ left alone (not valid JSON/TOML, or a `lanes` entry that starts something else) or lane-mcp is not installed."""
537
+ if not args or args[0] != 'install':
538
+ sys.exit(MCP_USAGE)
539
+ agents, write, rest = C.MCP_AGENTS, False, list(args[1:])
540
+ while rest:
541
+ word = rest.pop(0)
542
+ if word == '--agent' and rest and rest[0] in ('claude', 'codex', 'all'):
543
+ pick = rest.pop(0)
544
+ agents = C.MCP_AGENTS if pick == 'all' else (pick,)
545
+ elif word == '--write':
546
+ write = True
547
+ else:
548
+ sys.exit(MCP_USAGE)
549
+ home = Path.home()
550
+ binary = ROOT / 'bin' / 'lane-mcp'
551
+ missing = not os.access(str(binary), os.X_OK)
552
+ if missing:
553
+ print(f'! {binary} is not installed — on the host run: lanes install-container <container>')
554
+ refused = False
555
+ for item in C.mcp_plan(home, agents):
556
+ if item['why']:
557
+ refused = True
558
+ print(f"! {item['path']}: {item['why']}")
559
+ elif item['after'] == item['before']:
560
+ print(f"{item['path']}: the lanes MCP server is already there")
561
+ else:
562
+ sys.stdout.write(C.mcp_diff(item))
563
+ if not write:
564
+ print('dry run: nothing written (add --write to apply)')
565
+ sys.exit(1 if refused or missing else 0)
566
+ if missing:
567
+ sys.exit('lane mcp install: nothing written — an entry would start a lane-mcp that is not there')
568
+ added = False
569
+ for result in C.mcp_apply(home, agents):
570
+ if result['status'] == 'refused':
571
+ refused = True
572
+ print(f"! {result['path']}: {result['why']} — nothing written")
573
+ elif result['status'] == 'added':
574
+ added = True
575
+ if result['backup']:
576
+ print(f"backup {result['backup']}")
577
+ print(f"wrote {result['path']}")
578
+ if added:
579
+ print('new Claude Code and Codex sessions load it (a running session keeps its tools)')
580
+ sys.exit(1 if refused else 0)
581
+
582
+
583
+ PUBLISH_USAGE = 'usage: lane publish <file> [--title T] [--for <login>] [--kind md|html|svg|file]'
584
+
585
+
586
+ def cmd_publish(args):
587
+ """`lane publish` (plan W10 W4): lane_core.publish from the lane this process runs in (lane_core.whoami: $LANE_ID,
588
+ else the tmux pane) for the file named relative to the working directory. Prints the answer as JSON on stdout
589
+ (what lanes_publish answers) and one line for a person on stderr. A refusal is one stderr line, exit 1."""
590
+ options, rest, path = {}, list(args), None
591
+ while rest:
592
+ word = rest.pop(0)
593
+ if word in ('--title', '--for', '--kind'):
594
+ if not rest or word in options:
595
+ sys.exit(PUBLISH_USAGE)
596
+ options[word] = rest.pop(0)
597
+ elif word.startswith('--') or path is not None:
598
+ sys.exit(PUBLISH_USAGE)
599
+ else:
600
+ path = word
601
+ if path is None:
602
+ sys.exit(PUBLISH_USAGE)
603
+ found = C.whoami(ROOT, os.environ)
604
+ if not found['lane']:
605
+ sys.exit(f"lane publish: {found.get('why') or 'not inside a lane'}")
606
+ try:
607
+ out = C.publish(ROOT, Path(os.environ.get('HOME') or Path.home()), os.path.abspath(path), found['lane'],
608
+ title=options.get('--title'), for_login=options.get('--for'), kind=options.get('--kind'))
609
+ except C.LaneError as error:
610
+ sys.exit(f'lane publish: {error}')
611
+ out.update(picked_up_by=C.PUBLISH_PICKUP, expected_within_s=C.PUBLISH_LATENCY_S)
612
+ print(json.dumps(out, ensure_ascii=False, indent=1))
613
+ sys.stderr.write(f"lane publish: {out['path']} ({out['kind']}, {out['bytes']} bytes) -> request {out['id']} of "
614
+ f"{out['lane']}; {C.PUBLISH_PICKUP} takes it within ~{C.PUBLISH_LATENCY_S} s and answers in "
615
+ f"{out['receipt']}\n")
616
+
617
+
563
618
  COMMANDS = {'new': cmd_new, 'open': cmd_open, 'attach': cmd_attach, 'send': cmd_send, 'capture': cmd_capture, 'ls': cmd_ls,
564
- 'status': cmd_status, 'kill': cmd_kill, 'gc': cmd_gc}
619
+ 'status': cmd_status, 'kill': cmd_kill, 'gc': cmd_gc, 'mcp': cmd_mcp, 'publish': cmd_publish}
565
620
 
566
621
  if __name__ == '__main__':
567
622
  if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
@@ -0,0 +1,100 @@
1
+ #!/bin/sh
2
+ # lane-edit — the editor Ctrl-Alt-e opens in a workspace container (W9 E3, LNS-97). Installed at ~/.lz/bin/lane-edit
3
+ # by `lanes install-container`, next to ~/.lz/vimrc. `lanes edit --focused` (W9 E2, bin/lanes edit_argv) picks the
4
+ # lane's file on the host and starts this with no shell in between:
5
+ # docker exec -it -u coder -w <checkout root> -e TERM=xterm-256color -e COLORTERM=truecolor -e LANG=C.UTF-8
6
+ # -e LANES_EDITOR=<choice or empty> -e LANES_EDIT_LABEL=<label> <container> /home/coder/.lz/bin/lane-edit <path>
7
+ # usage: lane-edit <file>
8
+ #
9
+ # * umask 077 first, so anything the editor creates (a swap file, a backup copy) is this account's alone. A file
10
+ # written in place keeps its own mode and owner.
11
+ # * The editor: $LANES_EDITOR when it names a program on PATH — one word, a name or an absolute path, never a
12
+ # command line: no arguments, no shell — else the first of micro hx nvim vim vi on PATH. Lanes installs no
13
+ # editor; vi is the floor. A $LANES_EDITOR that names nothing is said on stderr, and the list decides.
14
+ # * vim and nvim — by name, or a vi that is vim behind a link (Debian's alternatives) — start with
15
+ # `-u ~/.lz/vimrc` when it is there: written in place, swap and backup files under ~/.lz/tmp/ (created here,
16
+ # 0700) instead of beside the file in the checkout, no modelines, no added final newline, no viminfo.
17
+ # * One header line, `editing <label> · <directory> · <how to finish>` ($LANES_EDIT_LABEL, else the file's name;
18
+ # control characters dropped), then the editor replaces this shell: its exit status is lane-edit's, and
19
+ # quitting it closes the floating pane.
20
+ # * The file reaches the editor as one argv word; one that starts with `-` goes as ./<name>, never as an option.
21
+ # Exit: the editor's status; 2 usage; 127 no editor on PATH. Both refusals wait for Enter on a terminal first, as
22
+ # `lanes edit` does on the host: the pane (close_on_exit) would take the message with it.
23
+ umask 077
24
+ set -u
25
+
26
+ hold() { # the Edit pane closes the moment this exits: on a terminal, let a refusal be read first
27
+ if [ -t 0 ]; then
28
+ printf '\nPress Enter to close.' >&2
29
+ read -r _ || :
30
+ fi
31
+ }
32
+
33
+ if [ $# -ne 1 ] || [ -z "$1" ]; then
34
+ echo 'usage: lane-edit <file>' >&2
35
+ hold
36
+ exit 2
37
+ fi
38
+ file=$1
39
+ case $file in -*) file=./$file ;; esac
40
+ root=${HOME:-/home/coder}/.lz
41
+ candidates='micro hx nvim vim vi'
42
+ editor='' note='' vim=''
43
+
44
+ clean() { # $1 without control characters: a label or a directory never moves the cursor or retitles the terminal
45
+ printf '%s' "$1" | tr -d '\000-\037\177'
46
+ }
47
+
48
+ program() { # the absolute path of the program $1 names on PATH; nothing for a builtin, function, folder or plain file
49
+ found=$(command -v -- "$1" 2>/dev/null) || return 1
50
+ case $found in
51
+ /*) [ -f "$found" ] && [ -x "$found" ] && printf '%s' "$found" ;;
52
+ *) return 1 ;;
53
+ esac
54
+ }
55
+
56
+ vim_named() { # whether the basename of $1 is vim or nvim (vim.basic, vim.tiny, nvim.appimage too)
57
+ case ${1##*/} in vim | vim.* | nvim | nvim.*) return 0 ;; esac
58
+ return 1
59
+ }
60
+
61
+ if [ -n "${LANES_EDITOR:-}" ]; then
62
+ editor=$(program "$LANES_EDITOR") || editor=
63
+ if [ -z "$editor" ]; then
64
+ note="lane-edit: LANES_EDITOR=$(clean "$LANES_EDITOR") is not a program on PATH"
65
+ fi
66
+ fi
67
+ if [ -z "$editor" ]; then
68
+ for name in $candidates; do
69
+ editor=$(program "$name") && break
70
+ editor=
71
+ done
72
+ if [ -n "$note" ]; then
73
+ if [ -n "$editor" ]; then echo "$note; using ${editor##*/}" >&2; else echo "$note" >&2; fi
74
+ fi
75
+ fi
76
+ if [ -z "$editor" ]; then
77
+ echo "lane-edit: no editor on PATH: set LANES_EDITOR, or install one of $candidates in this container" >&2
78
+ hold
79
+ exit 127
80
+ fi
81
+
82
+ if vim_named "$editor" || vim_named "$(readlink -f "$editor" 2>/dev/null)"; then
83
+ vim=1
84
+ fi
85
+ case ${editor##*/} in
86
+ micro) finish='Ctrl-s, Ctrl-q to finish' ;;
87
+ vi | hx) finish=':wq to finish' ;;
88
+ *) if [ -n "$vim" ]; then finish=':wq to finish'; else finish='save and quit to finish'; fi ;;
89
+ esac
90
+
91
+ label=${LANES_EDIT_LABEL:-}
92
+ [ -n "$label" ] || label=${1##*/}
93
+ printf 'editing %s · %s · %s\n' "$(clean "$label")" "$(clean "$PWD")" "$finish"
94
+
95
+ if [ -n "$vim" ] && [ -r "$root/vimrc" ]; then
96
+ mkdir -p "$root/tmp" 2>/dev/null
97
+ chmod 700 "$root/tmp" 2>/dev/null
98
+ exec "$editor" -u "$root/vimrc" "$file"
99
+ fi
100
+ exec "$editor" "$file"