@aron98/load-lock 1.4.0 → 1.6.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.
- package/README.md +61 -14
- package/dist/adapters/opencode/plugin.js +15 -1
- package/dist/adapters/opencode/tui.js +7 -2
- package/dist/cli/copies.d.ts +3 -10
- package/dist/cli/copies.js +32 -44
- package/dist/cli/hook.js +48 -4
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +68 -14
- package/dist/cli/main.js +108 -211
- package/dist/cli/service.d.ts +56 -0
- package/dist/cli/service.js +261 -0
- package/dist/cli/setup.d.ts +131 -0
- package/dist/cli/setup.js +942 -0
- package/dist/cli/update.d.ts +57 -0
- package/dist/cli/update.js +287 -0
- package/dist/core/daemon.d.ts +29 -3
- package/dist/core/daemon.js +111 -11
- package/dist/core/persist.d.ts +3 -0
- package/dist/core/persist.js +49 -10
- package/dist/core/state.d.ts +9 -0
- package/dist/core/state.js +41 -3
- package/dist/dashboard/feed.d.ts +23 -0
- package/dist/dashboard/feed.js +64 -2
- package/dist/dashboard/frame.js +2 -0
- package/dist/dashboard/view.d.ts +11 -0
- package/dist/dashboard/view.js +13 -0
- package/dist/mcp/server.d.ts +11 -3
- package/dist/mcp/server.js +38 -6
- package/dist/policy/text.d.ts +13 -3
- package/dist/policy/text.js +14 -3
- package/dist/shared/agent-guide.d.ts +3 -0
- package/dist/shared/agent-guide.js +16 -0
- package/dist/shared/client.js +16 -4
- package/dist/shared/drift.d.ts +23 -0
- package/dist/shared/drift.js +95 -0
- package/dist/shared/identity.d.ts +51 -0
- package/dist/shared/identity.js +116 -0
- package/dist/shared/installs.d.ts +39 -0
- package/dist/shared/installs.js +101 -0
- package/dist/shared/pkginfo.d.ts +25 -0
- package/dist/shared/pkginfo.js +70 -0
- package/dist/shared/protocol.d.ts +22 -0
- package/dist/shared/protocol.js +15 -0
- package/dist/shared/runtimes.d.ts +41 -0
- package/dist/shared/runtimes.js +81 -0
- package/dist/shared/version.d.ts +6 -0
- package/dist/shared/version.js +9 -1
- package/dist/test/ace-mcp.test.d.ts +1 -0
- package/dist/test/ace-mcp.test.js +157 -0
- package/dist/test/ace-opencode.test.d.ts +1 -0
- package/dist/test/ace-opencode.test.js +287 -0
- package/dist/test/check-pins.test.d.ts +1 -0
- package/dist/test/check-pins.test.js +108 -0
- package/dist/test/compat.test.d.ts +1 -0
- package/dist/test/compat.test.js +190 -0
- package/dist/test/hook.test.js +179 -1
- package/dist/test/identity.test.d.ts +1 -0
- package/dist/test/identity.test.js +155 -0
- package/dist/test/install.test.js +135 -1
- package/dist/test/launcher.test.d.ts +1 -0
- package/dist/test/launcher.test.js +222 -0
- package/dist/test/mcp.test.js +76 -7
- package/dist/test/plugin.test.d.ts +1 -0
- package/dist/test/plugin.test.js +144 -0
- package/dist/test/reconnect.test.d.ts +1 -0
- package/dist/test/reconnect.test.js +389 -0
- package/dist/test/runtimes.test.d.ts +1 -0
- package/dist/test/runtimes.test.js +282 -0
- package/dist/test/setup.test.d.ts +1 -0
- package/dist/test/setup.test.js +354 -0
- package/dist/test/update-command.test.d.ts +1 -0
- package/dist/test/update-command.test.js +679 -0
- package/dist/test/update.test.js +33 -1
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +48 -2
- package/docs/ARCHITECTURE.md +122 -7
- package/docs/COMPATIBILITY.md +156 -13
- package/docs/OPERATIONS.md +407 -21
- package/package.json +3 -2
- package/skills/load-lock/SKILL.md +62 -0
package/dist/test/update.test.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - `service stop` waits bounded, escalates to SIGKILL, verifies the
|
|
10
10
|
* socket is free, and reports honestly
|
|
11
11
|
* - `service start` refusals name the conflicting pid
|
|
12
|
-
* - `doctor` reports installed copies and warns on drift
|
|
12
|
+
* - `doctor` reports installed copies (incl. npx-cache, #57) and warns on drift
|
|
13
13
|
*
|
|
14
14
|
* Modeled after daemon.test.ts: real daemons over real sockets in temp
|
|
15
15
|
* state dirs (LL_TEST_TMP / $TMPDIR/opencode — never the user's real dir).
|
|
@@ -453,3 +453,35 @@ test("doctor: reports installed copies, versions, daemon skew, and copy drift",
|
|
|
453
453
|
rmSync(fakePkg, { recursive: true, force: true });
|
|
454
454
|
}
|
|
455
455
|
});
|
|
456
|
+
test("doctor: lists a planted npx-cache copy (issue #57)", async () => {
|
|
457
|
+
const d = await startDaemon();
|
|
458
|
+
const home = mkdtempSync(join(TMP_BASE, "ll-npxhome-"));
|
|
459
|
+
try {
|
|
460
|
+
// npx layout under the default npm cache: ~/.npm/_npx/<hash>/node_modules/@aron98/load-lock
|
|
461
|
+
const npxCopy = join(home, ".npm", "_npx", "0123abcd4567ef89", "node_modules", "@aron98", "load-lock");
|
|
462
|
+
mkdirSync(npxCopy, { recursive: true });
|
|
463
|
+
writeFileSync(join(npxCopy, "package.json"), JSON.stringify({ name: "@aron98/load-lock", version: "0.4.2" }));
|
|
464
|
+
// A foreign npx entry is ignored.
|
|
465
|
+
const foreign = join(home, ".npm", "_npx", "ffff", "node_modules", "@aron98", "load-lock");
|
|
466
|
+
mkdirSync(foreign, { recursive: true });
|
|
467
|
+
writeFileSync(join(foreign, "package.json"), JSON.stringify({ name: "something-else", version: "1.0.0" }));
|
|
468
|
+
const env = { ...d.env, HOME: home };
|
|
469
|
+
// npm sets npm_config_cache for scripts it runs (`npm test`); the test
|
|
470
|
+
// must resolve the planted temp HOME's default cache, never the real one.
|
|
471
|
+
delete env.npm_config_cache;
|
|
472
|
+
delete env.NPM_CONFIG_CACHE;
|
|
473
|
+
const r = await runCliEnv(env, ["doctor", "--json"]);
|
|
474
|
+
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
|
|
475
|
+
const report = JSON.parse(r.stdout);
|
|
476
|
+
const copies = report.copies;
|
|
477
|
+
const npx = copies.filter((c) => c.source === "npx-cache");
|
|
478
|
+
assert.equal(npx.length, 1, `exactly the planted npx copy: ${JSON.stringify(copies)}`);
|
|
479
|
+
assert.equal(npx[0].version, "0.4.2");
|
|
480
|
+
assert.equal(npx[0].path, npxCopy);
|
|
481
|
+
assert.ok(report.warnings.some((w) => w.includes("multiple load-lock versions") && w.includes("npx-cache=0.4.2")), `drift warning names the npx copy: ${JSON.stringify(report.warnings)}`);
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
await d.stop();
|
|
485
|
+
rmSync(home, { recursive: true, force: true });
|
|
486
|
+
}
|
|
487
|
+
});
|
package/docs/ADAPTERS.md
CHANGED
|
@@ -52,8 +52,8 @@ adapter does — see `src/adapters/opencode/plugin.ts` as the worked example):
|
|
|
52
52
|
## Tier 2 — hooks + MCP
|
|
53
53
|
|
|
54
54
|
For harnesses with lifecycle hooks but no in-process API (Claude Code and
|
|
55
|
-
Codex CLI use this): keep the stock MCP server (`load-lock-mcp` binary,
|
|
56
|
-
`node .../dist/mcp/main.js`) for tools, and add hook handlers that shell out
|
|
55
|
+
Codex CLI use this): keep the stock MCP server (`load-lock-mcp` binary,
|
|
56
|
+
`load-lock mcp`, or `node .../dist/mcp/main.js`) for tools, and add hook handlers that shell out
|
|
57
57
|
to the CLI:
|
|
58
58
|
|
|
59
59
|
- `load-lock hook <harness> session-start` → prints `POLICY_BRIEF` +
|
|
@@ -96,6 +96,7 @@ normally touches:
|
|
|
96
96
|
| `heartbeat {agentId}` | keep untracked leases alive |
|
|
97
97
|
| `watch {agentId\|*}` / `events {sinceEventId, agentId?}` | push/pull event streams (durable ids) |
|
|
98
98
|
| `offers_for_cwd {cwd}` | for hook scripts without session identity |
|
|
99
|
+
| `hook_once {harness, sessionId, event, occurrence?, ttlMs?, hold?}` | hook dedupe: `{first}` is true only for the first caller per key; keys expire (TTL, default 60s) or, with `hold`, live as long as the connection; in-memory only |
|
|
99
100
|
|
|
100
101
|
## Lifecycle contract (the part that must not be improvised)
|
|
101
102
|
|
package/docs/AGENT_GUIDE.md
CHANGED
|
@@ -6,8 +6,42 @@ wide capacity budget. Before resource-intensive work, acquire a permit. The
|
|
|
6
6
|
whole thing is cooperative: you decide what is heavy; the budget only works
|
|
7
7
|
if everyone participates.
|
|
8
8
|
|
|
9
|
-
This guide is also available as
|
|
10
|
-
supported harnesses.
|
|
9
|
+
This guide is also available as the `agent_guide` MCP tool, as
|
|
10
|
+
`load-lock agent-guide`, and as a skill in supported harnesses.
|
|
11
|
+
|
|
12
|
+
## 0. How to reach load-lock
|
|
13
|
+
|
|
14
|
+
Use the first of these that works; they all talk to the same daemon:
|
|
15
|
+
|
|
16
|
+
1. **load-lock tools** in your harness (MCP tools, or `load_lock_*` in
|
|
17
|
+
OpenCode). The rest of this guide names the tools.
|
|
18
|
+
2. **`load-lock` on PATH** (check with `load-lock version`).
|
|
19
|
+
3. **Pinned npx**: `npx -y @aron98/load-lock@<version> <command>`, with the
|
|
20
|
+
version the load-lock skill pins (Node >= 20). Read `load-lock` below as
|
|
21
|
+
this whole prefix.
|
|
22
|
+
|
|
23
|
+
The first call starts the shared daemon; you never start it yourself.
|
|
24
|
+
|
|
25
|
+
**CLI only (skill-only install, no tools).** Prefer `load-lock run`: it
|
|
26
|
+
acquires, runs the command, and releases when it exits, success or failure:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
load-lock run --reason "full test suite" --op-key test/all -- npm test
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Exit code **68** means "not granted yet" (queued, with the request id in
|
|
33
|
+
the message): don't start the work. Keep doing light work; when you run out,
|
|
34
|
+
`load-lock wait --id <request-id>` sleeps until your turn, claims the offer
|
|
35
|
+
and prints a `permit_token` for `load-lock run --permit <permit_token> -- <cmd>`.
|
|
36
|
+
A `wait` that exits 68 again is a checkpoint: call it again. Your own
|
|
37
|
+
requests and offers: `load-lock pending`.
|
|
38
|
+
|
|
39
|
+
Your agent id is derived from your harness session, so all calls from one
|
|
40
|
+
session count as one agent. If `pending` doesn't show a request you made,
|
|
41
|
+
pin the id yourself with `LOAD_LOCK_AGENT=<unique-name>` (or `--agent`) on
|
|
42
|
+
every call. Without tools nothing wakes you while you sit idle and nothing
|
|
43
|
+
re-teaches the policy after context compaction; `load-lock setup` shows how
|
|
44
|
+
to add the harness integration that does.
|
|
11
45
|
|
|
12
46
|
## 1. Deciding heavy vs light
|
|
13
47
|
|
|
@@ -219,3 +253,15 @@ When you hold the permit, you own a share of the machine — bound your own
|
|
|
219
253
|
work too: prefer e.g. `cargo test -j 2` or a bounded test-worker count over
|
|
220
254
|
unbounded parallelism, and publish what you did via
|
|
221
255
|
`configure(... guidance ...)` notes if it should become convention.
|
|
256
|
+
|
|
257
|
+
## 7. Keeping load-lock itself current
|
|
258
|
+
|
|
259
|
+
If load-lock tells you its adapter copy is newer than the installed CLI
|
|
260
|
+
(`load-lock: this load-lock adapter copy (vX) is newer than the installed
|
|
261
|
+
CLI (vY). Run \`load-lock update\` ...`), run it **as an ordinary command**
|
|
262
|
+
(CLI: `load-lock update`; MCP/plugin: spawn the CLI or tell the user) —
|
|
263
|
+
never try to install anything behind the notice; it is notify-only.
|
|
264
|
+
`load-lock update --check` is the safe dry run: exit 0 = up to date,
|
|
265
|
+
1 = update available (or this copy cannot self-apply it — repo checkouts
|
|
266
|
+
and opencode-managed copies are refused honestly), 70 = failure. A
|
|
267
|
+
successful apply restarts the service itself; you do not need to.
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -99,8 +99,13 @@ wrong-state error), and a failed start kills the process group immediately.
|
|
|
99
99
|
Background execution is handled honestly: the helper returns after *the whole
|
|
100
100
|
process group* finishes, never merely when a shell returns a task id.
|
|
101
101
|
|
|
102
|
-
**Identity.** Per-agent identity
|
|
103
|
-
|
|
102
|
+
**Identity.** Per-agent identity is derived from the harness session
|
|
103
|
+
(issues #49/#50): an explicit `agent_id` / `--agent` / `LOAD_LOCK_AGENT`
|
|
104
|
+
wins, else a harness session id (OpenCode `sessionID`, Claude Code
|
|
105
|
+
`CLAUDE_CODE_SESSION_ID`, hook `session_id`), else the stable harness
|
|
106
|
+
ancestor process on Linux, so every wiring of one session (CLI, MCP
|
|
107
|
+
server, hooks) acts as one agent (details in
|
|
108
|
+
[COMPATIBILITY.md](COMPATIBILITY.md#session-identity-per-harness)); every entry also records an agent label,
|
|
104
109
|
cwd, op key (for idempotent duplicate suppression), and reason. Idempotency:
|
|
105
110
|
a duplicate `acquire` from the same agent for the same pending op key returns
|
|
106
111
|
the existing request rather than a second one; multiple distinct ops per
|
|
@@ -118,10 +123,86 @@ same request ids (idempotent re-acquire keeps working), entries offered at
|
|
|
118
123
|
crash time restart as queued and are re-offered with fresh tokens — so a
|
|
119
124
|
restart no longer silently drops a queue position. The daemon auto-spawns on
|
|
120
125
|
the next client connection, and a *self-ordered* restart (version handshake:
|
|
121
|
-
a newer client than the daemon)
|
|
122
|
-
|
|
126
|
+
a newer client than the daemon) persists state and respawns onto the newer
|
|
127
|
+
client's copy (#53; same argv when that copy can't be validated) — see the update lifecycle in
|
|
123
128
|
[OPERATIONS.md](OPERATIONS.md#update-lifecycle).
|
|
124
129
|
|
|
130
|
+
**Persisted format compatibility (issue #51).** Several runtime copies may
|
|
131
|
+
share one state dir, so a downgrade is possible: a newer daemon dies and an
|
|
132
|
+
older client starts its older copy, which must then load files the newer one
|
|
133
|
+
wrote. `config.json` carries a top-level `formatVersion` (absent = 0, the
|
|
134
|
+
pre-versioning format); the journal stays a bare JSON array (what every
|
|
135
|
+
released daemon reads) with `formatVersion` stamped on each entry. The
|
|
136
|
+
promise, in both directions:
|
|
137
|
+
|
|
138
|
+
- Format changes are **additive**. A reader never rejects a file because its
|
|
139
|
+
`formatVersion` is higher than its own; it uses what it understands.
|
|
140
|
+
- Unknown fields are **never dropped destructively**: top-level and
|
|
141
|
+
`config`/pool/guidance fields of `config.json` survive a rewrite (e.g. a
|
|
142
|
+
`configure`), and unknown fields on a journal entry are written back while
|
|
143
|
+
that entry lives. Each writer stamps its *own* `formatVersion`, so readers
|
|
144
|
+
must treat fields from a newer format as possibly stale.
|
|
145
|
+
- Unknown journal entry kinds (a `state` this version doesn't know) and
|
|
146
|
+
malformed entries are **skipped**, never fatal; the count is reported in the
|
|
147
|
+
`daemon_recovered` event. A held entry with an unknown `phase` is kept as an
|
|
148
|
+
untracked permit with a fresh lease (capacity stays accounted; the lease
|
|
149
|
+
reclaims it if nobody heartbeats).
|
|
150
|
+
- An incompatible change would use a **new file name**, never reinterpret
|
|
151
|
+
`config.json` / `journal.json`.
|
|
152
|
+
|
|
153
|
+
## Three layers: daemon, runtime copies, harness wiring
|
|
154
|
+
|
|
155
|
+
load-lock reaches a machine through many entry points (a skill, an MCP
|
|
156
|
+
registration, a harness plugin, ACE, `npm i -g` + `load-lock install`), and
|
|
157
|
+
users mix them across harnesses and over time. The design rule (issue #47):
|
|
158
|
+
**any combination, installed in any order, gives one working budget.** It
|
|
159
|
+
holds because load-lock is split into three layers with different
|
|
160
|
+
multiplicity:
|
|
161
|
+
|
|
162
|
+
| Layer | Per machine | What it is | Rule |
|
|
163
|
+
| --- | --- | --- | --- |
|
|
164
|
+
| **Daemon** | exactly 1 (one socket, one state dir) | the long-lived process that owns all state, started on demand by whichever client connects first (`src/shared/client.ts`) | the newest copy runs it |
|
|
165
|
+
| **Runtime copies** | many | installed copies of the npm package: global npm/bun, the npx cache, OpenCode's plugin cache, a git checkout; the Claude Code plugin's launcher picks one of them | copies interoperate in both directions; none "owns" the daemon |
|
|
166
|
+
| **Harness wiring** | ideally 1 per harness, possibly several | MCP registrations, hooks, policy text, skill copies, written by a plugin, ACE, `load-lock install` or by hand | duplicates are **harmless**, not merely discouraged |
|
|
167
|
+
|
|
168
|
+
**Duplicates are harmless.** Each overlap is defused where the singleton
|
|
169
|
+
lives, not by hoping the user cleans up:
|
|
170
|
+
|
|
171
|
+
- *Hooks*: two wirings of the same hook (loose + plugin, two plugins, ACE)
|
|
172
|
+
both fire, but the daemon's `hook_once` lets only the first act — one
|
|
173
|
+
policy injection, one Stop continuation, one idle watcher per session
|
|
174
|
+
(see "Duplicate hook wiring is harmless" below).
|
|
175
|
+
- *MCP servers*: two registrations in one session (e.g. ACE's `mcp.json` +
|
|
176
|
+
the plugin's bundled server) are two processes with the **same**
|
|
177
|
+
session-derived identity (see Identity above), so an `acquire` through
|
|
178
|
+
one shows up in `pending` through the other. Both talk to the one daemon.
|
|
179
|
+
- *Skill copies*: identical text; the skill only tells the agent how to
|
|
180
|
+
reach the daemon.
|
|
181
|
+
|
|
182
|
+
Housekeeping is still offered: `load-lock setup` lists every integration
|
|
183
|
+
per harness and, with `--yes`, converges each harness to one wiring at its
|
|
184
|
+
highest rung (see [OPERATIONS.md](OPERATIONS.md#load-lock-setup-what-is-installed-where)).
|
|
185
|
+
Correctness never depends on running it.
|
|
186
|
+
|
|
187
|
+
**Compatibility promise between copies.** Because several copies of
|
|
188
|
+
different versions talk to one daemon and one state dir:
|
|
189
|
+
|
|
190
|
+
- every client envelope carries its version and package root; a strictly
|
|
191
|
+
newer client makes the daemon persist state and respawn onto that
|
|
192
|
+
client's copy (#52/#53, see "Runtime copies and the restart hint"
|
|
193
|
+
below), with one attempt per version;
|
|
194
|
+
- the persisted files are versioned and additive in both directions, so
|
|
195
|
+
an older daemon started after a newer one died reads the newer files
|
|
196
|
+
safely (#51, "Persisted format compatibility" above);
|
|
197
|
+
- newer client features degrade against an older daemon instead of
|
|
198
|
+
failing: hooks without `hook_once` act as before, `wait_for_completion`
|
|
199
|
+
falls back to an events-based poll.
|
|
200
|
+
|
|
201
|
+
The **install ladder** built on top of this (skill → MCP server → harness
|
|
202
|
+
plugin, plus `npm i -g` + `load-lock install`) is described in
|
|
203
|
+
[OPERATIONS.md](OPERATIONS.md#the-install-ladder); which rung each harness
|
|
204
|
+
reaches is in [COMPATIBILITY.md](COMPATIBILITY.md#rung-per-harness).
|
|
205
|
+
|
|
125
206
|
## Notification honesty
|
|
126
207
|
|
|
127
208
|
A critical constraint discovered by research: **no harness reliably pushes
|
|
@@ -168,7 +249,8 @@ mechanisms, and labels exactly which apply per harness:
|
|
|
168
249
|
calls `ctx.session.synthetic(...)`, delivering the offer (with token)
|
|
169
250
|
to an idle session at its next execution boundary — it never
|
|
170
251
|
interrupts an in-flight shell command.
|
|
171
|
-
- Claude Code (
|
|
252
|
+
- Claude Code (live-verified in Docker with the loose install; the
|
|
253
|
+
plugin's hooks are not yet live-verified, #63): a `Stop` hook checks for cwd-matching
|
|
172
254
|
offers and continues the session with a claim instruction; an async
|
|
173
255
|
`await-offer` watcher with `asyncRewake: true` wakes idle sessions.
|
|
174
256
|
- Codex CLI (live-verified in Docker): `Stop` hook with `decision: "block"`
|
|
@@ -191,8 +273,10 @@ and restored after context compaction:
|
|
|
191
273
|
- **OpenCode**: plugin `context` hook injects the brief on every model call
|
|
192
274
|
(compaction-proof, live-verified), plus a registered skill with the full
|
|
193
275
|
guide.
|
|
194
|
-
- **Claude Code**: `SessionStart` hook (
|
|
195
|
-
|
|
276
|
+
- **Claude Code**: `SessionStart` hook (re-fires after `compact`), from
|
|
277
|
+
either the `load-lock-plugin` plugin or the loose install; the loose
|
|
278
|
+
install adds a `~/.claude/CLAUDE.md` section + skill, the plugin ships
|
|
279
|
+
neither (full guide via the `agent_guide` MCP tool).
|
|
196
280
|
- **Codex CLI**: `SessionStart` hook (matcher `compact`) + `SubagentStart`
|
|
197
281
|
hook + `~/.codex/AGENTS.md` section (hooks require the interactive
|
|
198
282
|
hook-trust review once; live-verified in Docker).
|
|
@@ -203,6 +287,21 @@ Policy is short (one brief, no repeated reminders), reinforced by tool
|
|
|
203
287
|
names/descriptions, and never interrupts ordinary work with approval
|
|
204
288
|
prompts.
|
|
205
289
|
|
|
290
|
+
**Duplicate hook wiring is harmless (issue #54).** Loose hooks, a plugin
|
|
291
|
+
and ACE wiring may all be installed at once, so one harness event can fire
|
|
292
|
+
the same load-lock hook several times. The daemon is the singleton, so it
|
|
293
|
+
drops duplicates: each hook handler first calls `hook_once` with the key
|
|
294
|
+
`(harness, session_id, event, occurrence)`; only the first caller acts, the
|
|
295
|
+
rest exit 0 silently. The occurrence separates legitimate repeats:
|
|
296
|
+
SessionStart uses its `source` (a later `compact` re-injects the policy),
|
|
297
|
+
Stop hooks (Claude Code, Codex, Cursor) use the offer token being delivered
|
|
298
|
+
(each offer becomes at most one continuation per session). Keys expire on
|
|
299
|
+
their own (default 60s) and are never persisted; the `await-offer` watcher
|
|
300
|
+
instead *holds* its key for its connection's lifetime, so a session has at
|
|
301
|
+
most one live watcher and the key frees when the watcher exits. Hooks fail
|
|
302
|
+
open: no session id, a daemon without `hook_once` (`unknown method`), or
|
|
303
|
+
any error means the hook acts exactly as before.
|
|
304
|
+
|
|
206
305
|
## Failure modes covered
|
|
207
306
|
|
|
208
307
|
| Failure | Behavior |
|
|
@@ -217,6 +316,22 @@ prompts.
|
|
|
217
316
|
| Config change while offers outstanding | Capacity increase admits queued agents immediately; capacity decrease lets holders finish and delays new admissions; outstanding offers keep their reservations |
|
|
218
317
|
| Duplicate claims/releases | Idempotent or precise `stale_token`/`offer_expired` errors, never double effects |
|
|
219
318
|
|
|
319
|
+
### Runtime copies and the restart hint
|
|
320
|
+
|
|
321
|
+
`daemon.json` records the daemon's package root and version, and
|
|
322
|
+
`runtimes.json` the copies seen via the handshake (#52). When a strictly
|
|
323
|
+
newer client connects, the daemon respawns onto that client's copy (#53)
|
|
324
|
+
if the envelope's `clientRoot` names a `@aron98/load-lock` package whose
|
|
325
|
+
on-disk version equals the claimed `clientVersion` and which ships
|
|
326
|
+
`dist/core/daemon-main.js`; otherwise it reruns its own argv. The
|
|
327
|
+
`LOAD_LOCK_RESTARTED_FOR` marker keeps one restart per version either way.
|
|
328
|
+
|
|
329
|
+
Security: the hint arrives over the daemon's socket, which only the same
|
|
330
|
+
user can reach. Following it grants no privilege the client did not
|
|
331
|
+
already have — that client could run the same file itself. The checks
|
|
332
|
+
exist to avoid respawning onto garbage (wrong package, mismatched version,
|
|
333
|
+
missing entry), not as a trust boundary.
|
|
334
|
+
|
|
220
335
|
## Extension points
|
|
221
336
|
|
|
222
337
|
The protocol already carries weights (a request may ask for >1 units of
|
package/docs/COMPATIBILITY.md
CHANGED
|
@@ -46,6 +46,30 @@ Status labels mean exactly this:
|
|
|
46
46
|
| **omac** (agent sandbox; verified with 0.11.0) | N/A — omac is a sandbox runtime, not a harness: the agent inside it uses whichever harness adapter applies (OpenCode, Claude Code, …) | via the inner harness | via the inner harness | via the inner harness | via the inner harness | Full for the sandbox grants: `load-lock uninstall --harness omac` removes exactly the entries install added (match-own-entries, empty sections pruned) | **Sandbox-profile grants installed + live-verified** (`install --harness omac`: `filesystem.allow_unix_dir` += state dir, `filesystem.read` += package tree when needed, `environment.allow_vars` += `LOAD_LOCK_*`) |
|
|
47
47
|
| **Aider** | **No MCP support** | CONVENTIONS.md / `.aider.conf.yml` | Read each run | N/A | N/A | CLI-only: same-shell `load-lock acquire ... && cmd; load-lock release ...` (manual) | Proposed (manual CLI tier) |
|
|
48
48
|
|
|
49
|
+
## Rung per harness
|
|
50
|
+
|
|
51
|
+
The install ladder (OPERATIONS.md, "The install ladder") has rungs 0 = skill
|
|
52
|
+
only, 1 = MCP server, 2 = harness plugin, plus the loose
|
|
53
|
+
`npm i -g` + `load-lock install --harness <h>` wiring. **Rung 0 works
|
|
54
|
+
everywhere** an agent can read the skill and run a shell: it only needs
|
|
55
|
+
the CLI (on PATH or through the skill's pinned `npx`), so it is not
|
|
56
|
+
repeated per row. `load-lock setup` reports the rung it finds per harness
|
|
57
|
+
as `plugin`, `loose`, `MCP only`, `skill only` or `not installed`.
|
|
58
|
+
|
|
59
|
+
| Harness | MCP server (1) | Plugin (2) | Loose `install` | Highest rung |
|
|
60
|
+
| --- | --- | --- | --- | --- |
|
|
61
|
+
| **OpenCode** | generic stdio registration works; the plugin's native `load_lock_*` tools are better | npm plugin `@aron98/load-lock` (also listed on ACE via `ace/opencode/`) — live-verified | `install --harness opencode` registers that same npm plugin | **plugin** |
|
|
62
|
+
| **Claude Code** | ACE `ace/mcp` or a manual stdio registration | `load-lock-plugin` from this repo's marketplace — contract-tested, passes `claude plugin validate --strict`; not yet live-verified as a plugin (pending #63); ACE's skills-dir install unverified (#63/#66) | `install --harness claude-code`: MCP + hooks + `CLAUDE.md` section + skill — live-verified in Docker | **plugin** (loose gives the same capability) |
|
|
63
|
+
| **Codex CLI** | `[mcp_servers]` entry in `~/.codex/config.toml` | none shipped | `install --harness codex`: MCP + hooks + `AGENTS.md` section — live-verified in Docker | **loose** |
|
|
64
|
+
| **Cursor** | project `.cursor/mcp.json` | none shipped | `install --harness cursor --project <dir>`: MCP + stop hook + rule — contract-tested | **loose** |
|
|
65
|
+
| **Gemini CLI, Copilot CLI, Cline, Roo Code, Goose** | the harness's MCP config | none shipped | `install --harness <h>`: MCP + instructions/rule file, no hooks — config-tested | **loose** (rung 1 plus a policy file; no idle wakeup) |
|
|
66
|
+
| **omac** | n/a (sandbox, not a harness) | — | `install --harness omac`: sandbox-profile grants — live-verified | the inner harness's rung |
|
|
67
|
+
| **Aider** | no MCP support | none | none | **0** (CLI only) |
|
|
68
|
+
|
|
69
|
+
"Highest rung" is what `load-lock setup --yes` converges a harness to once
|
|
70
|
+
it has some load-lock wiring (OPERATIONS.md, "Converging"). Mixing rungs in
|
|
71
|
+
one harness is safe (ARCHITECTURE.md, "Three layers").
|
|
72
|
+
|
|
49
73
|
## How each integration tier works
|
|
50
74
|
|
|
51
75
|
### Tier 1 — OpenCode (native plugin, live-verified)
|
|
@@ -73,6 +97,23 @@ tool round-trip (acquire → queued → wakeup → claim → release), execution
|
|
|
73
97
|
boundary delivery without interrupting a running `sleep`, skill visible,
|
|
74
98
|
and graceful handling of a deliberately missed offer (paused → cancel).
|
|
75
99
|
|
|
100
|
+
#### ACE listing (opencode, issue #79)
|
|
101
|
+
|
|
102
|
+
ACE ([TNG/ace](https://github.com/TNG/ace)) cannot detect the root
|
|
103
|
+
`package.json` as an OpenCode plugin, because its entry point is the
|
|
104
|
+
gitignored `dist/`. The OpenCode listing therefore comes from the dedicated
|
|
105
|
+
folder `ace/opencode/`. It is a thin pointer: `index.js` re-exports
|
|
106
|
+
`@aron98/load-lock`, and `package.json` (`load-lock-opencode`, private) pins
|
|
107
|
+
that dependency to the release version through `scripts/check-pins.mjs`.
|
|
108
|
+
ACE finds the folder through its npm-package heuristic (a `main` that exists
|
|
109
|
+
in the git tree, plus `opencode` in the name or keywords). ACE marks every
|
|
110
|
+
such detection as **"verify before registering"**, so that note is expected.
|
|
111
|
+
`src/test/ace-opencode.test.ts` mirrors the heuristic (TNG/ace@b703410) and
|
|
112
|
+
asserts that this repo yields exactly one OpenCode plugin, at `ace/opencode`.
|
|
113
|
+
Users install the published package as usual (a `plugins` entry
|
|
114
|
+
`@aron98/load-lock` in `opencode.json`), not the folder itself. The
|
|
115
|
+
end-to-end ACE install is not yet live-verified (#66).
|
|
116
|
+
|
|
76
117
|
#### Multiple copies of load-lock (opencode)
|
|
77
118
|
|
|
78
119
|
`install --harness opencode` registers the **bare package name** in
|
|
@@ -89,15 +130,37 @@ Which copy is authoritative for what:
|
|
|
89
130
|
(it refreshes the cache entry); you cannot reliably update it by hand.
|
|
90
131
|
- **CLI commands** (`load-lock status`, `service`, `doctor`, …) — the copy on
|
|
91
132
|
PATH (typically a global install via npm/bun). Update it with your usual
|
|
92
|
-
global install command
|
|
133
|
+
global install command, or `load-lock update` (since #38), which detects
|
|
134
|
+
the package manager from the copy's location and refuses to touch copies
|
|
135
|
+
it does not manage: it will not "update" the opencode cache copy (opencode
|
|
136
|
+
owns it) nor a git checkout (your git workflow owns it), and it restarts
|
|
137
|
+
the service after a successful apply.
|
|
93
138
|
- **The daemon's code** — whichever copy spawned it (usually the CLI copy,
|
|
94
139
|
since auto-spawn and `service start` run from the CLI client code). The
|
|
95
140
|
version handshake makes a daemon self-restart onto updated code when a
|
|
96
|
-
newer client contacts it
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
141
|
+
newer client contacts it. Since #53 the respawn moves onto the newer
|
|
142
|
+
client's own copy (validated from its `clientRoot` hint), so a daemon
|
|
143
|
+
spawned from a different copy than the one you updated follows the
|
|
144
|
+
newer client. Clients older than #52 send no `clientRoot`; for them the
|
|
145
|
+
respawn reuses the daemon's original argv and only picks up an in-place
|
|
146
|
+
update of its spawning copy — `load-lock service restart` (since #38)
|
|
147
|
+
re-spawns it from the current CLI copy.
|
|
148
|
+
- **npx-launched copies** (since #57) — skill / ACE / pinned MCP entries run
|
|
149
|
+
`npx -y @aron98/load-lock@<pin>`, which installs a copy under the npm
|
|
150
|
+
cache at `~/.npm/_npx/<hash>/node_modules/@aron98/load-lock/` (or under
|
|
151
|
+
`$npm_config_cache/_npx`; `%LocalAppData%\npm-cache\_npx` on Windows).
|
|
152
|
+
`doctor` lists these as `npx-cache`. They are owned by the pin that
|
|
153
|
+
launches them: `load-lock update` refuses to touch them and points at
|
|
154
|
+
bumping the pinned version (or the ACE artifact) instead.
|
|
155
|
+
|
|
156
|
+
The mirror drift — a *newer* adapter copy (opencode refreshed its cache)
|
|
157
|
+
with an *older* CLI on PATH — surfaces at adapter init (issue #38): the
|
|
158
|
+
plugin/MCP server prints one clear notice naming `load-lock update` and
|
|
159
|
+
embeds it in the injected context/instructions. Notify only, never
|
|
160
|
+
auto-install. See
|
|
161
|
+
[OPERATIONS.md](OPERATIONS.md#adapter-init-drift-notice) for the
|
|
162
|
+
in-memory-staleness limitation (a running session keeps the old adapter
|
|
163
|
+
code until the harness restarts).
|
|
101
164
|
|
|
102
165
|
`load-lock doctor` lists every copy it can find (PATH-resolved CLI, this
|
|
103
166
|
running copy, opencode cache copies) with versions and warns when they
|
|
@@ -121,6 +184,51 @@ observed live on this machine before the feature existed.
|
|
|
121
184
|
idle Claude. The token is deliberately NOT in that message (stderr may be
|
|
122
185
|
shown); the agent claims via `pending`/`claim`.
|
|
123
186
|
|
|
187
|
+
**Tool names depend on the wiring.** A loose install (`load-lock install`)
|
|
188
|
+
registers the MCP server as `load-lock`, so Claude Code exposes
|
|
189
|
+
`mcp__load-lock__<tool>`. A plugin-bundled server is namespaced by plugin
|
|
190
|
+
and server key, `mcp__plugin_<plugin>_<server>__<tool>` (per the Claude Code
|
|
191
|
+
MCP docs), e.g. `mcp__plugin_load-lock-plugin_load-lock__claim` for the
|
|
192
|
+
`load-lock-plugin` Claude Code plugin. Every text
|
|
193
|
+
load-lock injects (policy brief, hook claim/wakeup messages, CLAUDE.md /
|
|
194
|
+
AGENTS.md sections, skill) therefore names tools neutrally ("the load-lock
|
|
195
|
+
`claim` tool") instead of hard-coding a prefix.
|
|
196
|
+
|
|
197
|
+
**Claude Code plugin** (`/plugin install load-lock-plugin@load-lock`, see
|
|
198
|
+
OPERATIONS "Claude Code plugin"): the same MCP server and hooks, bundled
|
|
199
|
+
and run through the dependency-free launcher; no skill, the detailed guide
|
|
200
|
+
comes from the `agent_guide` tool. Its manifest, hooks file and
|
|
201
|
+
marketplace entry are contract-tested (`src/test/plugin.test.ts`) and pass
|
|
202
|
+
`claude plugin validate --strict` (Claude Code 2.1.312), and the hook
|
|
203
|
+
commands are tested the way the plugin invokes them. Hooks use shell form
|
|
204
|
+
(one `command` string, `${CLAUDE_PLUGIN_ROOT}` quoted; #63).
|
|
205
|
+
|
|
206
|
+
**Plugin live verification** (#63; Claude Code 2.1.312, 2026-10-29, Linux
|
|
207
|
+
host): a real session with a throwaway `CLAUDE_CONFIG_DIR` and `HOME`,
|
|
208
|
+
load-lock isolated by `LOAD_LOCK_STATE_DIR` / `LOAD_LOCK_SOCKET`, the
|
|
209
|
+
plugin installed from this repo's marketplace with `claude plugin
|
|
210
|
+
marketplace add <checkout>` + `claude plugin install
|
|
211
|
+
load-lock-plugin@load-lock`, and the checkout's CLI first on `PATH`.
|
|
212
|
+
Headless `claude -p` and an interactive pty session. Status: **plugin
|
|
213
|
+
live-verified** for the checks marked observed:
|
|
214
|
+
|
|
215
|
+
| Check | Result |
|
|
216
|
+
| --- | --- |
|
|
217
|
+
| Shell-form hooks run, launcher takes the PATH branch | Observed (`load-lock launcher: path 1.5.0` on every hook) |
|
|
218
|
+
| SessionStart policy at startup | Observed (model quoted the isolated daemon's `load-lock now:` line) |
|
|
219
|
+
| SessionStart policy after `/compact` | Observed (`SessionStart:compact` hook ran; model quoted the fresh `1/1` line where the startup brief said `0/1`) |
|
|
220
|
+
| MCP tools named `mcp__plugin_load-lock-plugin_load-lock__<tool>` | Observed (all 19 tools listed under that prefix; server `plugin:load-lock-plugin:load-lock` connected) |
|
|
221
|
+
| `agent_guide` tool | Observed (called; returned the guide) |
|
|
222
|
+
| Stop offer continuation | Observed (headless: Stop hook returned `decision: block` with the offer token; model claimed and released) |
|
|
223
|
+
| `await-offer` idle wakeup (`async` + `asyncRewake`) | Observed (interactive: session idle ~18s, offer arrived, the watcher's exit 2 woke the session with a task notification; model ran `pending` → `claim` → `release`) |
|
|
224
|
+
| Daemon started by the plugin | Observed (fresh state dir; the daemon came up during session start with `CLAUDE_PLUGIN_ROOT` in its environment and `daemon.json` naming the PATH copy). Which plugin component spawned it first (hook or MCP server) was not determined. |
|
|
225
|
+
| Session-derived identity | Observed for the plugin MCP server (requests carried agent `claude-code:<session id>` matching the session's transcript). That hooks read the same `session_id` from stdin was not separately inspected. |
|
|
226
|
+
| Loose install + plugin together | Not observed (not run; covered by the #54 dedupe tests only) |
|
|
227
|
+
| Launcher npx / known-copy branches inside a live session | Not observed (only the PATH branch ran) |
|
|
228
|
+
|
|
229
|
+
The TUI labels the asyncRewake wake as "Stop hook blocking error from
|
|
230
|
+
command SessionStart:startup"; the message text is the watcher's.
|
|
231
|
+
|
|
124
232
|
Contract tests cover all hook stdin/stdout shapes against the documented
|
|
125
233
|
harness contracts. Claude Code is additionally **live-verified** (in Docker,
|
|
126
234
|
via `docker/run-claude-verify.sh` against the real `claude` CLI): SessionStart
|
|
@@ -253,7 +361,7 @@ without a dedicated installer.
|
|
|
253
361
|
Idle policy: these harnesses get no push notifications, so the PRIMARY
|
|
254
362
|
idle mechanism is the MCP `wait_for_turn` tool — a bounded, honest sleep
|
|
255
363
|
until it is the caller's turn. Queued agents call
|
|
256
|
-
|
|
364
|
+
the load-lock `wait_for_turn` tool (optionally with `timeout_ms`, default
|
|
257
365
|
600000, capped by the machine's `maxTurnWaitMs` config) and get a
|
|
258
366
|
structured result on every path: `granted` (the offer was auto-claimed —
|
|
259
367
|
`permit_token` included), `still_queued` (timeout, with queue position
|
|
@@ -319,7 +427,7 @@ UI** — where no UI surface exists, we say so rather than claim parity.
|
|
|
319
427
|
| Harness | In-harness dashboard | Also available |
|
|
320
428
|
| --- | --- | --- |
|
|
321
429
|
| **OpenCode** | Native TUI panel: `/load-lock` opens `load-lock.dashboard` **in-window** as a side panel (also opened by clicking the sidebar widget; deliberately **not** bound to any key — issue #28: the old `gl` sequence swallowed plain `g` typing, and the candidate `ctrl+g` is the host's `session.first` default) with pools, holder cards, offer claim-window countdowns, queue, paused, event feed, `inspect_machine`; home-footer `lock 1/1` indicator on the home screen plus a clickable, health-colored `load-lock 0/3` widget in the session sidebar; `x` force-release behind select + confirm. Implemented as the package's `./tui` companion plugin (main plugin unchanged). Live-verified: opencode 2.0.29 (panel render, palette/slash command, confirm-gated force-release) and opencode 2.0.32 in a real pty (sidebar widget render, click-to-open, in-window presentation, plain `g` reaches the composer, `ctrl+g` not intercepted by load-lock). | `load-lock dashboard` (alias `top`) |
|
|
322
|
-
| **Claude Code** | None in-app — Claude Code
|
|
430
|
+
| **Claude Code** | None in-app — load-lock ships no in-app view for Claude Code: `load-lock-plugin` bundles the MCP server + hooks only, and the loose install adds no UI either. The model can report status via the load-lock `status` MCP tool at tool boundaries, and each `SessionStart` hook injects a one-line machine summary. | `load-lock dashboard` (any terminal) |
|
|
323
431
|
| **Codex CLI** | None in-app — same reason. `load_lock_status` MCP tool; `SessionStart`/`SubagentStart` machine summary. | `load-lock dashboard` (alias `top`) |
|
|
324
432
|
| **Gemini CLI** | None in-app (no hooks, no UI plugin surface). `load_lock_status` MCP tool at tool boundaries. | `load-lock dashboard` (alias `top`) |
|
|
325
433
|
| **Copilot CLI, Cursor, Cline, Roo Code, Goose** | None in-app. MCP `status` tool where the MCP server is configured. | `load-lock dashboard` (alias `top`) |
|
|
@@ -334,14 +442,49 @@ reconnect, view-model shaping, frame rendering) is covered by
|
|
|
334
442
|
|
|
335
443
|
## Session identity per harness
|
|
336
444
|
|
|
445
|
+
The daemon routes offers, `pending` and op-key duplicate suppression by agent
|
|
446
|
+
id, so each harness session needs exactly one id, whichever wiring (CLI, MCP
|
|
447
|
+
server, hook) made the call. The default identity of the CLI (issue #49) and
|
|
448
|
+
of the MCP server (issue #50) comes from the shared resolver in
|
|
449
|
+
`src/shared/identity.ts`, first match wins:
|
|
450
|
+
|
|
451
|
+
1. explicit override: `--agent` (CLI), `agent_id` (MCP tools), or
|
|
452
|
+
`LOAD_LOCK_AGENT`;
|
|
453
|
+
2. a harness session env var exported to the processes the harness spawns:
|
|
454
|
+
`CLAUDE_CODE_SESSION_ID` → `claude-code:<session_id>` (the same value hooks
|
|
455
|
+
receive as `session_id`; verified on Claude Code 2.1.x), and
|
|
456
|
+
`OPENCODE_SESSION_ID` → the bare `sessionID` (exported by OpenCode's bash
|
|
457
|
+
tool; verified on opencode 2.1.1). The bare id is exactly the native
|
|
458
|
+
plugin's agent id, so CLI calls from an OpenCode shell share the plugin's
|
|
459
|
+
identity;
|
|
460
|
+
3. the stable harness ancestor process (Linux): walk up from the CLI's parent
|
|
461
|
+
past shells and launcher wrappers (`sh`/`bash`/`zsh`/…, `env`, `timeout`,
|
|
462
|
+
`npm`/`npx`, nested `load-lock`) to the first other ancestor, identified as
|
|
463
|
+
`proc:<pid>:<start time>` (pid-reuse safe via `/proc/<pid>/stat`), labelled
|
|
464
|
+
`<comm>:<pid>`. This covers harnesses without a verified env var (Codex,
|
|
465
|
+
Cursor, Gemini CLI, …), since one session = one long-lived harness process;
|
|
466
|
+
4. otherwise (no harness ancestor, or no `/proc` — macOS/Windows without a
|
|
467
|
+
session env var) the legacy default: `cli:$USER@$HOSTNAME` for the CLI, a
|
|
468
|
+
random per-process `agt_…` id for the MCP server.
|
|
469
|
+
|
|
470
|
+
So two MCP registrations in one session (plugin-bundled + standalone) share
|
|
471
|
+
one id: an `acquire` through one shows in `pending` through the other, and
|
|
472
|
+
both heartbeat the same id (idempotent). The CLI and the MCP server of one
|
|
473
|
+
session share an id too when both see the same session env var or the same
|
|
474
|
+
harness ancestor; whether a harness exports its session env var to MCP
|
|
475
|
+
server processes is not verified for every harness.
|
|
476
|
+
|
|
477
|
+
Labels stay human-readable: session-derived ids get a short holder label
|
|
478
|
+
(`claude-code:ee690007`, `codex:12345`); explicit ids label themselves.
|
|
479
|
+
|
|
337
480
|
| Harness | Identity | Notes |
|
|
338
481
|
| --- | --- | --- |
|
|
339
|
-
| OpenCode | plugin context `sessionID` (also MCP `_meta` when used) | automatic |
|
|
340
|
-
| Claude Code | hooks get `session_id`; MCP tools take explicit `agent_id` |
|
|
341
|
-
| Codex | hooks get `session_id`; MCP tools take explicit `agent_id` | same |
|
|
342
|
-
| Cursor | hooks get `conversation_id` (no `cwd` — `workspace_roots[0]` identifies the request); MCP tools take explicit `agent_id` | same |
|
|
482
|
+
| OpenCode | plugin context `sessionID` (also MCP `_meta` when used); CLI via `OPENCODE_SESSION_ID` (same id) | automatic |
|
|
483
|
+
| Claude Code | hooks get `session_id`; CLI and MCP server via `CLAUDE_CODE_SESSION_ID`, else the harness ancestor; MCP tools also take explicit `agent_id` | automatic; explicit `agent_id` still wins |
|
|
484
|
+
| Codex | hooks get `session_id`; CLI and MCP server via harness ancestor process; MCP tools also take explicit `agent_id` | same |
|
|
485
|
+
| Cursor | hooks get `conversation_id` (no `cwd` — `workspace_roots[0]` identifies the request); CLI and MCP server via harness ancestor process; MCP tools also take explicit `agent_id` | same |
|
|
343
486
|
| omac (sandbox) | inner harness identity + `LOAD_LOCK_LABEL_PREFIX=omac` | dashboard shows holders as `omac:<label>` |
|
|
344
|
-
| Tier 3 | explicit `agent_id` argument | falls back to per-server-process id |
|
|
487
|
+
| Tier 3 | explicit `agent_id` argument; CLI and MCP server via harness ancestor process | MCP falls back to a per-server-process id only when no session is found |
|
|
345
488
|
|
|
346
489
|
## Platform matrix
|
|
347
490
|
|