@aron98/load-lock 1.1.0 → 1.2.1

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.
@@ -89,7 +89,12 @@ uninstall — see issue #19); not live-verified inside the real CLIs.
89
89
  override `LOAD_LOCK_STATE_DIR`).
90
90
  - Socket: `<state dir>/daemon.sock` (Windows: named pipe `\\.\pipe\load-lock`;
91
91
  override `LOAD_LOCK_SOCKET`).
92
- - **Auto-spawns** on first client connection; no service unit needed.
92
+ - **Auto-spawns** on first client connection; no service unit needed. Set
93
+ `LOAD_LOCK_NO_SPAWN` to any non-empty value except `0` to forbid
94
+ spawning (client-only mode for sandboxes — see
95
+ [Sandboxes](#sandboxes)).
96
+ - Also honored by the shared client: `LOAD_LOCK_LABEL_PREFIX` (prefix for
97
+ holder labels; see Sandboxes).
93
98
  - Files: `config.json` (settings + history), `journal.json` (held permits),
94
99
  `daemon.json` (pid), `spawn.lock` (bootstrap race guard).
95
100
 
@@ -169,16 +174,16 @@ alternate screen, so it works anywhere the CLI works.
169
174
  The OpenCode installer also ships the package's `./tui` plugin export (a
170
175
  companion to the main plugin, which is unchanged). Inside the opencode TUI:
171
176
 
172
- - `/load-lock` (or the command palette, or the `gl` keybinding) opens the
177
+ - `/load-lock` (or the command palette) opens the
173
178
  **load-lock.dashboard** session panel **in-window** as a side panel —
174
179
  same data as `load-lock dashboard`, themed, live-updating, with the rest
175
180
  of the TUI layout still visible beside it.
176
181
  - Every session screen shows an at-a-glance widget at the bottom of the
177
182
  right sidebar: `load-lock 0/3` colored by pool health (green ok, yellow
178
183
  full/queued, red daemon down) plus a hint line. Clicking it opens the
179
- dashboard; `gl` is the fallback for terminals that don't deliver mouse
180
- events. The home screen keeps its `lock 1/1·Q2` footer indicator — the
181
- sidebar widget only renders on session screens, so the two never
184
+ dashboard; `/load-lock` is the fallback for terminals that don't deliver
185
+ mouse events. The home screen keeps its `lock 1/1·Q2` footer indicator —
186
+ the sidebar widget only renders on session screens, so the two never
182
187
  double-render.
183
188
  - `r` resyncs; `x` opens force-release, which requires an explicit
184
189
  select + confirm — the panel itself is read-only.
@@ -187,15 +192,31 @@ companion to the main plugin, which is unchanged). Inside the opencode TUI:
187
192
  palette — is open, so Esc keeps cancelling the dialog instead of
188
193
  closing the panel underneath it.
189
194
 
195
+ The open command deliberately ships with **no global key binding** (issue
196
+ #28). Never bind unmodified single letters or letter sequences (the old
197
+ `gl`) at global scope: while the keymap engine waits for the next key of a
198
+ sequence it swallows the prefix, so plain `g` never reached the composer.
199
+ The obvious modifier chord, `ctrl+g`, is already the host's documented
200
+ default for `session.first` ("Navigate to first message") in opencode
201
+ >= 2.0.32, so binding it would shadow a host feature; per the issue's
202
+ fallback the command is unbound instead — the sidebar click, the command
203
+ palette, and `/load-lock` are the entry points. (The panel-scoped `r` and
204
+ `x` bindings are fine: they are `enabled` only while the dashboard panel
205
+ itself is fullscreen, when the composer is suspended and no typing can be
206
+ swallowed.)
207
+
190
208
  Verified live against opencode 2.0.32 (real pty session): the sidebar
191
209
  widget renders with live daemon data, a mouse press on the widget opens
192
- the dashboard in-window with the chat still visible beside it, `gl` opens
193
- it as well, and the `/load-lock` command appears in the slash suggestions.
210
+ the dashboard in-window with the chat still visible beside it, plain `g`
211
+ types into the composer with no keymap interception, `ctrl+g` is not
212
+ intercepted by load-lock (the host's documented `session.first` default
213
+ is left untouched), and the `/load-lock` command appears in the slash
214
+ suggestions and opens the panel.
194
215
  Earlier live verification (2.0.29): panel render, palette/slash entry
195
216
  points, and the confirm-gated force-release flow. Click handling itself
196
217
  is contract-level from opentui (mouse reporting enabled by default,
197
218
  mouse-down events bubble to slot renderables) — the pty test above
198
- exercises the full path; the `gl` binding works regardless.
219
+ exercises the full path; the `/load-lock` command works regardless.
199
220
  Plugins configured in `opencode.json` that expose `./tui` are loaded
200
221
  automatically by the CLI, so the published package needs no extra config.
201
222
  For local development against a checkout, opencode also auto-discovers
@@ -317,6 +338,10 @@ Limitations observed (all inherent to containers, not load-lock bugs):
317
338
  respawns with defaults (fine for ephemeral CI containers; volume-mount
318
339
  `~/.local/state/load-lock` if you want persistence).
319
340
 
341
+ The setup above gives the container its *own* daemon. To make a container
342
+ a thin client of the **host's** daemon instead (bridged socket +
343
+ `LOAD_LOCK_NO_SPAWN`), see the [Sandboxes](#sandboxes) section below.
344
+
320
345
  Reproduce from a checkout (needs `dist/` built; tarball from `npm pack`):
321
346
 
322
347
  ```sh
@@ -381,6 +406,424 @@ Caveats to watch for (unverified expectations):
381
406
  - Keep the state dir on the Linux filesystem (`~/.local/state/load-lock`),
382
407
  not on `/mnt/c` (drvfs/9p is slow and lock semantics differ).
383
408
 
409
+ ## Sandboxes
410
+
411
+ Sandboxed agents (bubblewrap/omac, Docker, macOS seatbelt, WSL2) face one
412
+ core problem: the budget belongs on the *host*, but a sandbox that cannot
413
+ see the host's daemon will happily auto-spawn its own useless local daemon
414
+ and fragment the budget. The recipes below all use the same two mechanisms:
415
+
416
+ 1. **Bridge the host daemon's socket into the sandbox.** The daemon listens
417
+ on an AF_UNIX *path* socket. Path sockets are scoped by the **mount**
418
+ namespace, not the network namespace — so a bind mount of the state dir
419
+ survives `--unshare-net` (bubblewrap), and only needs the right
420
+ filesystem mapping/allow rules elsewhere. Point the sandboxed client at
421
+ the bridged path with `LOAD_LOCK_SOCKET`.
422
+ 2. **Run the client in client-only mode** so it can never auto-spawn a
423
+ daemon inside the sandbox (see below).
424
+
425
+ A third mechanism matters once the client is inside a PID namespace:
426
+ **lease-mode (untracked) permits**, because the host daemon cannot
427
+ `kill(pid, 0)` a sandbox pid it cannot see (lifecycle below).
428
+
429
+ ### Client-only mode: `LOAD_LOCK_NO_SPAWN`
430
+
431
+ Set `LOAD_LOCK_NO_SPAWN=1` (any non-empty value except `0`) and the shared
432
+ client will never spawn a daemon, no matter which adapter asks:
433
+ `ensureDaemon` refuses before creating anything, and `connect` fails loudly
434
+ instead of falling back to a spawn. The error is actionable and points
435
+ here:
436
+
437
+ ```
438
+ load-lock: load-lock daemon is not reachable at /run/load-lock/daemon.sock
439
+ and auto-spawn is disabled (LOAD_LOCK_NO_SPAWN is set): this client is
440
+ client-only and started no daemon. Run the daemon on the host machine
441
+ (e.g. load-lock service start) and bridge its socket into this sandbox,
442
+ then point LOAD_LOCK_SOCKET at the bridged path — see the "Sandboxes"
443
+ section of docs/OPERATIONS.md for per-sandbox recipes (bubblewrap/omac,
444
+ Docker, seatbelt, WSL2). To allow spawning a daemon here instead, unset
445
+ LOAD_LOCK_NO_SPAWN. (code: unavailable)
446
+ ```
447
+
448
+ The CLI exits with code 70 for this failure (unavailable transport). Use
449
+ this mode inside every sandbox: it turns "mysteriously isolated budget"
450
+ into a loud, documented failure, and it is what the tests in
451
+ `src/test/sandbox.test.ts` enforce (no state dir is ever created, and the
452
+ error names the docs).
453
+
454
+ ### Label prefixes: identifying sandboxed holders
455
+
456
+ Sandboxed clients can prefix their holder labels so the dashboard shows
457
+ *where* a permit came from, e.g. `omac:issue-42 build`. Three equivalent
458
+ entry points, all composing `prefix:label` (or `prefix:<agentId>` when no
459
+ label was given) daemon-side:
460
+
461
+ - `LOAD_LOCK_LABEL_PREFIX` env var — the shared client injects it into
462
+ every `acquire` request (including CLI, MCP, plugin and hook paths)
463
+ unless a request already carries an explicit prefix. This is the natural
464
+ place for a sandbox wrapper (omac profile, Docker `-e`, bwrap
465
+ `--setenv`) to tag everything an agent does inside it.
466
+ - CLI: `--label-prefix` on `acquire` and `run`.
467
+ - MCP: the `label_prefix` input on the `acquire` tool; the RPC field is
468
+ `labelPrefix` (see `docs/ADAPTERS.md`).
469
+
470
+ A present-but-empty prefix is rejected (`labelPrefix must be a non-empty
471
+ string`); prefixes are trimmed.
472
+
473
+ ### Cross-PID-namespace permits: the lease-mode lifecycle
474
+
475
+ With `--unshare-pid` (bubblewrap) or default Docker containers, the host
476
+ daemon cannot see the sandboxed client's pid — verified live below: inside
477
+ the sandbox `kill -0 <host-daemon-pid>` fails with "No such process" while
478
+ the daemon is plainly running. That makes **tracked** permits
479
+ (`start_job` / `attach_pid`, liveness via `kill(pid, 0)` +
480
+ `/proc/<pid>/stat`) meaningless across PID namespaces. Worse, calling
481
+ `attach_pid` from *inside* the sandbox passes a pid that is invisible or
482
+ reused on the host; the daemon's liveness check fails immediately and the
483
+ permit is auto-released. Only use tracked mode when the host sees the same
484
+ pid you do (host-run jobs, or containers started with `--pid=host`).
485
+
486
+ **Lease mode** is the cross-namespace lifecycle:
487
+
488
+ 1. `acquire` → permit granted (holder label visible in the dashboard).
489
+ 2. `mark_started` → permit moves to `running_untracked` with a lease
490
+ deadline of `now + untrackedLeaseMs` (default 15 minutes,
491
+ configurable — see Configuration). No host-side pid is recorded.
492
+ 3. **Heartbeats renew the lease.** Heartbeat sources: an MCP server
493
+ process heartbeats every 30s for its own agent (`agent_id` /
494
+ `defaultAgentId`); the OpenCode plugin heartbeats every 30s per known
495
+ session. The CLI has **no** automatic heartbeat — for a CLI-held
496
+ untracked permit the lease is a hard ceiling, so finish within
497
+ `untrackedLeaseMs` or release promptly.
498
+ 4. `release` → capacity freed immediately, whatever remains on the lease.
499
+ 5. **Lease expiry:** if no heartbeat arrives before the deadline, the
500
+ daemon reclaims the permit and admits queued waiters, journaling
501
+ `untracked lease expired: no heartbeat from the owning agent`. After a
502
+ daemon restart, `restore` grants surviving untracked permits a *fresh*
503
+ lease window from restore time.
504
+
505
+ The same trap catches `load-lock run` / `run_with_permit`: those helpers
506
+ use *tracked* mode (`start_job` with the spawned job's pid). Run from
507
+ inside a PID-namespace sandbox, the pid is invisible to the host daemon,
508
+ whose liveness sweep then auto-releases the permit while the job is still
509
+ running. Inside sandboxes the recipe is therefore always:
510
+ `acquire` → `mark_started` (+ heartbeats where the harness provides them)
511
+ → explicit `release` — never `run`, `attach-pid`, or `start-job` from the
512
+ sandboxed side. Save `run` for host-side invocations where the daemon sees
513
+ the job's pid.
514
+
515
+ Tradeoff (deliberate, and the `recover list` output says so): an untracked
516
+ permit whose owner is alive but silent is reclaimed after the lease, so
517
+ overlap with an unobserved survivor is possible — e.g. two builds may run
518
+ concurrently for up to `untrackedLeaseMs` after a sandbox stopped
519
+ heartbeating. That is the price of not being able to see pids across
520
+ namespaces; keep heartbeating (or release promptly) and size
521
+ `untrackedLeaseMs` accordingly.
522
+
523
+ ### bubblewrap — live-verified
524
+
525
+ Verified 2026-10-20 with bubblewrap 0.6.1 on Linux 6.8.0-138-generic,
526
+ Node v22.23.2, load-lock 1.1.0 working tree (issue-29 branch). The daemon
527
+ ran on the *host* in an isolated state dir; every client call below ran
528
+ inside `bwrap --unshare-net --unshare-pid` and reached the **host** daemon.
529
+
530
+ 1. Start the daemon on the host, in a state dir you will bind into the
531
+ sandbox (here a temp dir; for real use `~/.local/state/load-lock`):
532
+
533
+ ```sh
534
+ LOAD_LOCK_STATE_DIR=/tmp/ll-bwrap-29 node dist/cli/main.js service start
535
+ # load-lock: daemon running on pid 2259769 (default socket)
536
+ ```
537
+
538
+ The socket appears at `/tmp/ll-bwrap-29/daemon.sock` with mode
539
+ `srwxrwxr-x` (775): connecting needs *write* on the socket inode, so a
540
+ sandbox running as the same user (or group) can talk to it.
541
+
542
+ 2. Run the CLI inside the sandbox, binding the state dir and pointing
543
+ `LOAD_LOCK_SOCKET` at it. Bind the **state dir**, not the socket file —
544
+ the socket inode is replaced when the daemon restarts, so a file-only
545
+ bind goes stale, while the dir bind follows the new socket
546
+ automatically. Binding to an existing path keeps this simple (see
547
+ caveats):
548
+
549
+ ```sh
550
+ bwrap --dev-bind / / --unshare-net --unshare-pid --proc /proc \
551
+ --bind /tmp/ll-bwrap-29 /tmp/ll-bwrap-29 \
552
+ --setenv LOAD_LOCK_SOCKET /tmp/ll-bwrap-29/daemon.sock \
553
+ --setenv LOAD_LOCK_NO_SPAWN 1 \
554
+ --setenv LOAD_LOCK_LABEL_PREFIX bwrap \
555
+ node dist/cli/main.js status --json
556
+ ```
557
+
558
+ Observed: `status --json` answered by the *host* daemon — same pid as
559
+ the host's `service status` (`2259769`, protocol v1, heavy pool
560
+ capacity 1, `untrackedLeaseMs` 900000). `--unshare-net` does not
561
+ interfere: the path socket is reached through the mount namespace.
562
+
563
+ 3. PID-namespace proof: inside the same sandbox,
564
+ `kill -0 2259769` → `No such process` — the host daemon pid is
565
+ invisible to the sandbox, confirming the client crossed namespaces via
566
+ the filesystem, and that lease mode (not tracked mode) is required for
567
+ long holds (below).
568
+
569
+ 4. Full permit round trip from inside the sandbox:
570
+
571
+ ```sh
572
+ # acquire (env LOAD_LOCK_LABEL_PREFIX=bwrap was set by bwrap):
573
+ node dist/cli/main.js acquire --op-key bwrap-check \
574
+ --reason "issue-29 live verification build" --label "issue-29 build"
575
+ # → permit prm_… ; entry.agentLabel = "bwrap:issue-29 build"
576
+
577
+ node dist/cli/main.js mark-started --permit prm_…
578
+ # → running_untracked (lease 900s)
579
+
580
+ node dist/cli/main.js release --permit prm_…
581
+ # → host pool "heavy": 0/1
582
+ ```
583
+
584
+ Host-side `status` while the permit was held:
585
+
586
+ ```
587
+ pool "heavy": 1/1 in use
588
+ held: bwrap:issue-29 build — issue-29 live verification build (5s) [untracked, leased]
589
+ ```
590
+
591
+ and `recover list` reported `[running_untracked] bwrap:issue-29 build`
592
+ with `heartbeat lease 895s remaining — reclaimed when the owning agent
593
+ stops heartbeating (overlap with an unobserved survivor is possible;
594
+ see docs)`. Five durable events on the host journal: `daemon_started`,
595
+ `daemon_recovered`, `granted bwrap:issue-29 build`, `started`,
596
+ `released`.
597
+
598
+ Caveats observed (verified):
599
+
600
+ - `bwrap` creates missing bind destinations by mkdir-ing them, so
601
+ `--bind <state-dir> /run/load-lock` failed here with
602
+ `bwrap: Can't mkdir /run/load-lock: Permission denied` (`/run` is
603
+ root-owned). Either bind at an existing path (as above), pre-create the
604
+ destination, or pick a parent you can write to.
605
+ - Client-only mode matters: with `LOAD_LOCK_NO_SPAWN=1` unset, a sandbox
606
+ whose bind/env was misconfigured would silently spawn its own daemon
607
+ inside the sandbox and split the budget. The tests in
608
+ `src/test/sandbox.test.ts` pin the loud failure instead.
609
+
610
+ ### omac — live-verified
611
+
612
+ Verified 2026-10-20 against **omac 0.11.0** (extracted package binary, no
613
+ root needed) on Linux, driving the load-lock CLI from the v1.2.0 worktree
614
+ build end-to-end from inside `omac sandbox run`. omac wraps bubblewrap +
615
+ Landlock on Linux (seatbelt on macOS with the same env-stripping) and
616
+ **strips the harness environment**, allowing through only variables
617
+ listed in the user-global sandbox profile —
618
+ `~/.config/omac/sandbox-profiles/default.json`. The profile is the whole
619
+ recipe, and `load-lock install --harness omac` writes it for you
620
+ (merge-only; idempotent; `load-lock uninstall --harness omac` removes
621
+ exactly the entries it added):
622
+
623
+ ```json
624
+ {
625
+ "filesystem": {
626
+ "allow_unix_dir": ["<load-lock state dir>"],
627
+ "read": ["<installed package tree>"]
628
+ },
629
+ "environment": { "allow_vars": ["LOAD_LOCK_*"] }
630
+ }
631
+ ```
632
+
633
+ - `filesystem.allow_unix_dir` += the state dir: AF_UNIX connect to the
634
+ host daemon socket plus read/write on the state dir — this is the
635
+ sandbox→host bridge (omac has no separate "bind" step; the grant is the
636
+ bridge).
637
+ - `environment.allow_vars` += `"LOAD_LOCK_*"` — one trailing-`*` prefix
638
+ entry (verified: omac 0.11.0 accepts prefix patterns) covering all four
639
+ client vars (`LOAD_LOCK_SOCKET`, `LOAD_LOCK_STATE_DIR`,
640
+ `LOAD_LOCK_NO_SPAWN`, `LOAD_LOCK_LABEL_PREFIX`).
641
+ - `filesystem.read` += the installed package tree — **only when
642
+ load-lock lives outside an already-granted workdir** (see gotcha 1).
643
+
644
+ The exact procedure that was verified (run it with a throwaway `HOME`
645
+ unless you want the grants in your real profile):
646
+
647
+ ```sh
648
+ # 0. scratch dirs + a current daemon built from the v1.2.0+ tree.
649
+ # GOTCHA 2: the machine's real daemon was still v1.1.0 — a daemon
650
+ # started before v1.2.0 silently strips the unknown labelPrefix field
651
+ # (zod), so prefixed labels disappear without any error. Upgrade and
652
+ # restart: load-lock service stop && load-lock service start. This
653
+ # verification deliberately
654
+ # used its own daemon on a temp state dir.
655
+ LOAD_LOCK_STATE_DIR=/tmp/ll-32-test node dist/cli/main.js service start
656
+
657
+ # 1. install the grants (merge-only; prints exactly what it merged)
658
+ HOME=/tmp/ll-32-home LOAD_LOCK_STATE_DIR=/tmp/ll-32-test \
659
+ node dist/cli/main.js install --harness omac
660
+
661
+ # 2. omac's strict parser accepts the profile (unknown fields are errors)
662
+ HOME=/tmp/ll-32-home omac sandbox run -- true # exit 0, no ERROR lines
663
+
664
+ # 3. sandboxed client -> host daemon round-trip.
665
+ # NOTE: standalone `omac sandbox run` starts at cwd "/" and grants NO
666
+ # workdir by default — `--workdir-access read` binds the invocation
667
+ # dir read-only, modeling what a real omac agent session gets for its
668
+ # project workdir (which is why a repo checkout needs no read grant).
669
+ # The env values are set inside the sandboxed command with `env`
670
+ # (`-- env VAR=... node ...`); with the LOAD_LOCK_* allow_vars entry
671
+ # in place they could equally be inherited from the outer shell.
672
+ HOME=/tmp/ll-32-home omac sandbox run --workdir-access read -- \
673
+ env LOAD_LOCK_SOCKET=/tmp/ll-32-test/daemon.sock LOAD_LOCK_NO_SPAWN=1 \
674
+ LOAD_LOCK_LABEL_PREFIX=omac \
675
+ node dist/cli/main.js acquire --op-key issue-32-live \
676
+ --reason "issue-32 live verification build" --label "issue-32 build" --json
677
+ # -> permit prm_…; host status shows:
678
+ # held: omac:issue-32 build — issue-32 live verification build [claimed, awaiting start]
679
+
680
+ # 4. host-side release (or any later step from inside the sandbox):
681
+ node dist/cli/main.js release --permit prm_… # -> releasedNow: true
682
+ ```
683
+
684
+ Also verified live:
685
+
686
+ - **`LOAD_LOCK_NO_SPAWN=1` error contract**: with the client pointed at a
687
+ missing socket inside the sandbox, the CLI exits **70** with the loud
688
+ "not reachable and auto-spawn is disabled … bridge its socket into this
689
+ sandbox" error instead of silently spawning a sandbox-local daemon.
690
+ - **Lease-mode lifecycle across the PID namespace** (same as the
691
+ bubblewrap case): `acquire` → `mark-started` (held, lease 900s) →
692
+ `release` (`releasedNow: true`), all issued from inside the sandbox;
693
+ long holds need lease mode because the host daemon pid is invisible to
694
+ the sandbox.
695
+ - **Install/uninstall round-trips against the real omac**: a populated
696
+ user profile (workdir/network/environment entries) survives install
697
+ byte-faithfully with our grants appended, omac's strict parser accepts
698
+ the merged file, and uninstall leaves the user entries intact; a
699
+ from-scratch profile is removed entirely on uninstall (with the omac
700
+ config tree pruned) and omac keeps running on its defaults.
701
+
702
+ The two gotchas that shaped the installer:
703
+
704
+ 1. **A global install needs a read grant for the package tree, not just
705
+ the bin dir.** omac's builtin grants cover `~/.bun/bin`, but the
706
+ launcher resolves the dist tree under
707
+ `~/.bun/install/global/node_modules/@aron98/load-lock/dist/…` —
708
+ ungranted, the CLI dies with `Cannot find module …` (exit 1) before
709
+ ever reaching the socket. Proven live both ways: the sandboxed CLI
710
+ from a global-style tree failed with exactly that error before the
711
+ grant, and completed the full acquire→mark-started→release round-trip
712
+ after `filesystem.read` covered the tree. A repo checkout needs no
713
+ read grant (the agent session's workdir already covers it) and the
714
+ installer says so honestly in its output.
715
+ 2. **Label prefixes need a current daemon on the host.** A daemon
716
+ started before v1.2.0 strips the unknown `labelPrefix` field silently
717
+ (zod); the dashboard shows the bare label with no error anywhere.
718
+ `load-lock service stop && load-lock service start` after upgrading.
719
+
720
+ What was NOT verified: omac's macOS seatbelt path (same profile schema,
721
+ per omac's docs, but the verification machine is Linux) and omac's agent
722
+ orchestration commands (`omac start`/`continue`/…) driving a real agent —
723
+ the round-trip was verified with the documented `omac sandbox run`
724
+ primitive those commands build on. The installer's contract tests cover
725
+ the merge/uninstall edge cases (`src/test/install.test.ts`).
726
+
727
+ ### Docker — contract-inspected
728
+
729
+ Docker *was* verified for in-container daemons (see the verified Docker
730
+ subsection above). What was **not** verified here is the thin-client shape
731
+ below: the verification machine has a `docker` binary but the verifying
732
+ user cannot reach `/var/run/docker.sock`, so this recipe is
733
+ contract-inspected — ready to run, expected outcomes marked, not executed
734
+ by us.
735
+
736
+ 1. Run the daemon on the host (normal install, default state dir).
737
+ 2. Run the container as a thin client of the *host* daemon:
738
+
739
+ ```sh
740
+ docker run --rm -it \
741
+ -v ~/.local/state/load-lock:/run/load-lock \
742
+ -e LOAD_LOCK_SOCKET=/run/load-lock/daemon.sock \
743
+ -e LOAD_LOCK_NO_SPAWN=1 \
744
+ -e LOAD_LOCK_LABEL_PREFIX=docker \
745
+ node:24 bash
746
+ # inside: npm install -g <load-lock tarball> && load-lock status
747
+ ```
748
+
749
+ Expected: `load-lock status` reports the *host* daemon's pid and pool
750
+ counts, and holders created inside the container appear on the host
751
+ dashboard as `docker:<label>`.
752
+
753
+ Notes and caveats (unverified expectations):
754
+
755
+ - Bind the **state dir**, not the socket file (the socket inode is
756
+ replaced on daemon restart; the dir bind follows it — same reasoning as
757
+ bubblewrap above).
758
+ - Connecting to the socket needs *write* on the socket inode (mode `775`,
759
+ user/group). Container root gets this via `CAP_DAC_OVERRIDE` when no user
760
+ namespace remapping is in play; for non-root containers run with
761
+ `--user "$(id -u):$(id -g)"` and rely on the group permission, or relax
762
+ the socket mode/group to match your setup.
763
+ - Container pids are not host pids, so — exactly as in the bubblewrap
764
+ case — use `mark_started` + heartbeat lease mode for long holds, never
765
+ `attach_pid`/`start_job` from inside, unless the container runs with
766
+ `--pid=host`.
767
+ - The verified caveats from the Docker subsection above still apply from
768
+ the client's point of view (`/proc/loadavg` and `nproc` reflect the
769
+ host; the container's own cgroup quota is what `inspect` reports).
770
+
771
+ ### macOS seatbelt — contract-inspected
772
+
773
+ **No macOS machine was available** for this verification, so this is
774
+ contract-inspected against the `sandbox-exec(5)`/seatbelt documentation,
775
+ not executed here.
776
+
777
+ On macOS the daemon listens on a Unix domain socket at
778
+ `~/.local/state/load-lock/daemon.sock`, and connecting to a UDS is
779
+ mediated by seatbelt as a *file* operation, not a network one — so a
780
+ profile needs read and write access to the socket path, e.g.:
781
+
782
+ ```lisp
783
+ (allow file-read-data
784
+ (literal "/Users/you/.local/state/load-lock/daemon.sock"))
785
+ (allow file-write*
786
+ (literal "/Users/you/.local/state/load-lock/daemon.sock"))
787
+ ```
788
+
789
+ (no `network-outbound` rules required for the path-socket transport;
790
+ `allow network*` forms are not the mechanism a UDS connect goes through
791
+ under seatbelt). Since the socket inode is recreated when the daemon
792
+ restarts, allow the containing directory too if your profile is
793
+ path-literal strict, or re-generate the profile. Expected outcome
794
+ (unverified): with those file rules plus the env vars
795
+ (`LOAD_LOCK_SOCKET`, `LOAD_LOCK_NO_SPAWN=1`,
796
+ `LOAD_LOCK_LABEL_PREFIX=…`) passed through to the agent, a seatbelt-sandboxed
797
+ client talks to the host daemon exactly as in the verified bubblewrap
798
+ case. omac's macOS sandboxing uses seatbelt under the hood with the same
799
+ `environment.allow_vars` env-stripping as on Linux, so the omac installer
800
+ (`load-lock install --harness omac`, live-verified on Linux against omac
801
+ 0.11.0 — see above) applies unchanged; only the socket path and the
802
+ seatbelt file rules differ.
803
+
804
+ ### WSL2 — honest state
805
+
806
+ There is no verified cross-boundary story for WSL2, and we won't pretend
807
+ otherwise:
808
+
809
+ - The Windows transport is a named pipe (`\\.\pipe\load-lock`); named
810
+ pipes are not reachable from inside a WSL2 distro. A WSL-side Unix
811
+ socket is likewise not reachable from Windows-native processes.
812
+ - There is deliberately no TCP transport (a non-goal), so there is no
813
+ loopback bridge to fake one.
814
+
815
+ The realistic shape: **the WSL2 Linux VM is the host.** Run the daemon
816
+ inside the distro (`~/.local/state/load-lock`, normal install — see the
817
+ WSL2 procedure subsection above for the unverified-but-expected resource
818
+ reporting), and treat Windows-native agents as out of scope for now.
819
+ Cross-distro bridging (two distros sharing one daemon via a `/mnt/wsl`
820
+ shared directory bind-mounted as the state dir) is an *unverified
821
+ expectation*: `/mnt/wsl` is shared across distros, so the same
822
+ bind-the-state-dir recipe should apply, but nobody has run it — verify
823
+ and record before relying on it. Keep the state dir on the Linux
824
+ filesystem, not `/mnt/c` (drvfs/9p is slow and lock semantics differ, as
825
+ in the WSL2 caveats above).
826
+
384
827
  ## macOS / Windows notes
385
828
 
386
829
  - macOS: no cgroup limits; process-liveness checks fall back from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aron98/load-lock",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Machine-wide counting semaphore coordinating resource-intensive work across parallel coding agents (OpenCode, Codex CLI, Claude Code, ...)",
5
5
  "type": "module",
6
6
  "license": "MIT",