@aron98/load-lock 1.2.0 → 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.
package/README.md CHANGED
@@ -59,7 +59,8 @@ agent A (OpenCode) agent B (Codex) agent C (Claude Code)
59
59
  ```sh
60
60
  npm install -g @aron98/load-lock
61
61
  load-lock install --harness opencode # or claude-code | codex | gemini-cli |
62
- # copilot-cli | cline | roo-code | goose | generic
62
+ # copilot-cli | cline | roo-code | goose |
63
+ # omac (sandbox grants) | generic
63
64
  load-lock doctor
64
65
  ```
65
66
 
@@ -88,7 +89,7 @@ WSL2) share the host's budget instead of getting their own: bridge the
88
89
  daemon's Unix socket into the sandbox, set `LOAD_LOCK_NO_SPAWN=1` so the
89
90
  client never spawns a useless sandbox-local daemon, and optionally tag
90
91
  holds with `LOAD_LOCK_LABEL_PREFIX` (e.g. `bwrap:issue-42 build`).
91
- Ready-to-run per-sandbox recipes — live-verified for bubblewrap,
92
+ Ready-to-run per-sandbox recipes — live-verified for bubblewrap and omac,
92
93
  contract-inspected for the rest, plus the cross-PID-namespace lease
93
94
  lifecycle — live in docs/OPERATIONS.md → "Sandboxes".
94
95
 
@@ -89,13 +89,17 @@ function dashboardLines(v, width, f) {
89
89
  const syncAge = v.lastSyncAt ? fmtDuration(Math.max(0, v.now - v.lastSyncAt)) : "never";
90
90
  push(`load-lock dashboard ${STATE_LABEL[v.state] ?? v.state} synced ${syncAge} ago`, stateColor(v.state, f));
91
91
  if (v.daemon) {
92
- push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion} · events through #${v.latestEventId}`, f.dim);
92
+ push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion}` +
93
+ (v.daemon.daemonVersion ? ` · daemon v${v.daemon.daemonVersion}` : "") +
94
+ ` · events through #${v.latestEventId}`, f.dim);
93
95
  }
94
96
  else {
95
97
  push(`daemon unreachable — it auto-spawns on the next load-lock command`, f.dim);
96
98
  }
97
99
  if (v.daemonRestartNote)
98
100
  push(`note: ${v.daemonRestartNote}`, f.warn);
101
+ if (v.versionSkewNote)
102
+ push(`note: ${v.versionSkewNote}`, f.warn);
99
103
  if (v.machine) {
100
104
  const m = v.machine;
101
105
  const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
@@ -0,0 +1,28 @@
1
+ export interface InstalledCopy {
2
+ /** Where this copy was found. */
3
+ source: "cli" | "path" | "opencode-cache";
4
+ /** Path of the copy's package root (its package.json's directory). */
5
+ path: string;
6
+ /** Version string, or null when the package.json could not be read. */
7
+ version: string | null;
8
+ }
9
+ /**
10
+ * Find the package.json of the @aron98/load-lock copy that contains the
11
+ * given file, by climbing parent directories. Best-effort: returns null if
12
+ * no enclosing package.json matches (or is readable).
13
+ */
14
+ export declare function enclosingPackage(startDir: string, maxDepth?: number): {
15
+ path: string;
16
+ version: string | null;
17
+ } | null;
18
+ /** The copy of load-lock this process runs from (its own package root). */
19
+ export declare function thisCopy(): InstalledCopy | null;
20
+ /**
21
+ * All installed copies of load-lock detectable on this machine:
22
+ * - "cli": the copy this process runs from.
23
+ * - "path": any `load-lock` binary found on PATH (deduped by real path,
24
+ * so a symlink to the running copy is recognized and not double-listed
25
+ * — a copy is only listed under "path" when it differs from "cli").
26
+ * - "opencode-cache": copies under opencode's private npm cache.
27
+ */
28
+ export declare function findInstalledCopies(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): InstalledCopy[];
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Installed-copy detection (issue #34): on a given machine several copies
3
+ * of load-lock commonly coexist — the globally-installed CLI on PATH, and
4
+ * the private copy opencode maintains under its npm cache (because
5
+ * `install --harness opencode` registers the bare package name, opencode
6
+ * installs and refreshes its own copy on its own schedule).
7
+ *
8
+ * All scanning is best-effort: missing paths, unreadable files, and
9
+ * unexpected layouts yield skipped entries or null versions — never a throw.
10
+ */
11
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { fileURLToPath } from "node:url";
15
+ import { isWindows } from "../shared/transport.js";
16
+ /** Package name of this package (used to find enclosing package roots). */
17
+ const PKG_NAME = "@aron98/load-lock";
18
+ /**
19
+ * Find the package.json of the @aron98/load-lock copy that contains the
20
+ * given file, by climbing parent directories. Best-effort: returns null if
21
+ * no enclosing package.json matches (or is readable).
22
+ */
23
+ export function enclosingPackage(startDir, maxDepth = 12) {
24
+ let dir = startDir;
25
+ for (let i = 0; i < maxDepth; i++) {
26
+ const pkgPath = join(dir, "package.json");
27
+ try {
28
+ if (existsSync(pkgPath)) {
29
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
30
+ if (pkg?.name === PKG_NAME) {
31
+ return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
32
+ }
33
+ // A foreign package.json terminates the climb (e.g. a PATH bin
34
+ // inside some other tool's package).
35
+ return null;
36
+ }
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ const parent = dirname(dir);
42
+ if (parent === dir)
43
+ return null;
44
+ dir = parent;
45
+ }
46
+ return null;
47
+ }
48
+ /** The copy of load-lock this process runs from (its own package root). */
49
+ export function thisCopy() {
50
+ // dist/cli/copies.js -> dist/cli -> ... -> <pkg>/package.json
51
+ const startDir = dirname(dirname(fileURLToPath(import.meta.url)));
52
+ const found = enclosingPackage(startDir);
53
+ if (!found)
54
+ return null;
55
+ return { source: "cli", path: found.path, version: found.version };
56
+ }
57
+ /**
58
+ * All installed copies of load-lock detectable on this machine:
59
+ * - "cli": the copy this process runs from.
60
+ * - "path": any `load-lock` binary found on PATH (deduped by real path,
61
+ * so a symlink to the running copy is recognized and not double-listed
62
+ * — a copy is only listed under "path" when it differs from "cli").
63
+ * - "opencode-cache": copies under opencode's private npm cache.
64
+ */
65
+ export function findInstalledCopies(platform = process.platform, env = process.env) {
66
+ const copies = [];
67
+ const seen = new Set();
68
+ const add = (c) => {
69
+ if (!c)
70
+ return;
71
+ let real;
72
+ try {
73
+ real = realpathSync(c.path);
74
+ }
75
+ catch {
76
+ real = c.path;
77
+ }
78
+ if (seen.has(real))
79
+ return;
80
+ seen.add(real);
81
+ copies.push(c);
82
+ };
83
+ add(thisCopy());
84
+ // --- PATH scan ------------------------------------------------------------
85
+ try {
86
+ const binNames = isWindows(platform)
87
+ ? ["load-lock.cmd", "load-lock.exe", "load-lock"]
88
+ : ["load-lock"];
89
+ const dirs = (env.PATH ?? "").split(isWindows(platform) ? ";" : ":").filter(Boolean);
90
+ for (const dir of dirs) {
91
+ for (const bin of binNames) {
92
+ try {
93
+ const binPath = join(dir, bin);
94
+ if (!statSync(binPath).isFile())
95
+ continue;
96
+ // A shim/wrapper script is not itself the copy — resolve through
97
+ // its enclosing package (works for npm/bun global layouts where
98
+ // the bin sits in <pkg>/bin or node_modules/.bin via a symlink).
99
+ let realBin;
100
+ try {
101
+ realBin = realpathSync(binPath);
102
+ }
103
+ catch {
104
+ realBin = binPath;
105
+ }
106
+ const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dir);
107
+ if (found)
108
+ add({ source: "path", path: found.path, version: found.version });
109
+ }
110
+ catch {
111
+ /* unreadable PATH entry — skip */
112
+ }
113
+ }
114
+ }
115
+ }
116
+ catch {
117
+ /* PATH scan is best-effort */
118
+ }
119
+ // --- opencode npm-cache scan ----------------------------------------------
120
+ // Layout (verified live): <cacheRoot>/opencode/npm/@aron98/load-lock@latest/<ts>/
121
+ // where "@aron98" and "load-lock@latest" are two nested directories (a
122
+ // normal npm scoped layout); URL-escaped single-directory variants like
123
+ // "@aron98%2Fload-lock@latest" also occur. Under each <ts>: either the
124
+ // real package at node_modules/@aron98/load-lock, or a top-level
125
+ // package.json (npm-install root).
126
+ try {
127
+ const cacheRoot = opencodeCacheRoot(platform, env);
128
+ if (cacheRoot) {
129
+ const npmDir = join(cacheRoot, "opencode", "npm");
130
+ if (existsSync(npmDir)) {
131
+ const scanTimestampDirs = (pkgBase) => {
132
+ let tsDirs = [];
133
+ try {
134
+ tsDirs = readdirSync(pkgBase).filter((d) => statSync(join(pkgBase, d)).isDirectory());
135
+ }
136
+ catch {
137
+ return;
138
+ }
139
+ for (const ts of tsDirs) {
140
+ const nested = join(pkgBase, ts, "node_modules", "@aron98", "load-lock");
141
+ const topLevel = join(pkgBase, ts);
142
+ for (const cand of [nested, topLevel]) {
143
+ try {
144
+ const pkgPath = join(cand, "package.json");
145
+ if (!existsSync(pkgPath))
146
+ continue;
147
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
148
+ if (pkg?.name !== PKG_NAME)
149
+ continue;
150
+ add({
151
+ source: "opencode-cache",
152
+ path: cand,
153
+ version: typeof pkg.version === "string" ? pkg.version : null,
154
+ });
155
+ break;
156
+ }
157
+ catch {
158
+ /* skip unreadable timestamp dir */
159
+ }
160
+ }
161
+ }
162
+ };
163
+ let scopeDirs = [];
164
+ try {
165
+ scopeDirs = readdirSync(npmDir).filter((d) => statSync(join(npmDir, d)).isDirectory());
166
+ }
167
+ catch {
168
+ scopeDirs = [];
169
+ }
170
+ for (const scopeDir of scopeDirs) {
171
+ if (scopeDir.startsWith("@aron98%2Fload-lock")) {
172
+ // URL-escaped single-directory form.
173
+ scanTimestampDirs(join(npmDir, scopeDir));
174
+ }
175
+ else if (scopeDir === "@aron98") {
176
+ // Scoped layout: @aron98/<name>@<version-range>/<ts>/...
177
+ let inner = [];
178
+ try {
179
+ inner = readdirSync(join(npmDir, scopeDir)).filter((d) => statSync(join(npmDir, scopeDir, d)).isDirectory());
180
+ }
181
+ catch {
182
+ continue;
183
+ }
184
+ for (const nameDir of inner) {
185
+ if (nameDir.startsWith("load-lock"))
186
+ scanTimestampDirs(join(npmDir, scopeDir, nameDir));
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ catch {
194
+ /* cache scan is best-effort */
195
+ }
196
+ return copies;
197
+ }
198
+ /** Cache root the way opencode resolves it (its npm cache lives under it). */
199
+ function opencodeCacheRoot(platform, env) {
200
+ if (env.XDG_CACHE_HOME)
201
+ return env.XDG_CACHE_HOME;
202
+ if (platform === "win32")
203
+ return env.LOCALAPPDATA ?? null;
204
+ if (platform === "darwin")
205
+ return join(env.HOME ?? homedir(), "Library", "Caches");
206
+ return join(env.HOME ?? homedir(), ".cache");
207
+ }
@@ -43,11 +43,22 @@
43
43
  * .cursor/rules/load-lock.mdc. Contract-tested against
44
44
  * the documented hooks JSON; not live-verified.
45
45
  * - generic : prints an MCP stdio config snippet (any MCP-capable harness).
46
+ * - omac : merges load-lock grants into omac's user-global sandbox
47
+ * profile (~/.config/omac/sandbox-profiles/default.json):
48
+ * filesystem.allow_unix_dir += the state dir (host-daemon
49
+ * socket bridge), filesystem.read += the installed package
50
+ * tree when load-lock lives outside any granted workdir
51
+ * (global bun/npm installs; gotcha verified live), and
52
+ * environment.allow_vars += "LOAD_LOCK_*". omac parses
53
+ * profiles strictly (unknown fields are errors) and never
54
+ * rewrites a profile it has created, so the installer only
55
+ * writes known fields and touches nothing else.
46
56
  */
47
57
  import fs from "node:fs";
48
58
  import os from "node:os";
49
59
  import path from "node:path";
50
60
  import { POLICY_BRIEF } from "../policy/text.js";
61
+ import { resolveStateDir } from "../shared/transport.js";
51
62
  const HOME = os.homedir();
52
63
  const PKG_NAME = "@aron98/load-lock";
53
64
  export function pkgRoot(binDir) {
@@ -948,6 +959,206 @@ function uninstallCursor(opts) {
948
959
  pruneEmptyDirs(cursorDir, cursorDir);
949
960
  return actions;
950
961
  }
962
+ // --------------------------------------------------------------------- omac
963
+ // omac (verified against 0.11.0) sandboxes agents with bubblewrap +
964
+ // Landlock on Linux and strips the harness environment, allowing through
965
+ // only what the user-global sandbox profile lists. Known profile fields
966
+ // (strict parsing — unknown fields are hard errors): top-level `workdir`,
967
+ // `network`, `filesystem`, `environment`; `workdir.access`,
968
+ // `network.allow_domain`/`deny_domain`/`allow_tcp_connect`,
969
+ // `filesystem.allow`/`read`/`write`/`allow_unix_dir`/`deny`,
970
+ // `environment.allow_vars` — all lists of strings. omac never rewrites a
971
+ // profile it has created, so the profile file is the whole extension
972
+ // point and we only ever write known fields.
973
+ const OMAC_LL_ENV_ENTRY = "LOAD_LOCK_*"; // allow_vars trailing-* prefix (verified: accepted by 0.11.0)
974
+ /** omac's user-global sandbox profile (no project equivalent exists). */
975
+ function omacProfileFile() {
976
+ return path.join(configHome(), "omac", "sandbox-profiles", "default.json");
977
+ }
978
+ /** Read + shape-check the profile; undefined when the file does not exist. */
979
+ function omacReadProfile(file) {
980
+ if (!fs.existsSync(file))
981
+ return undefined;
982
+ let parsed;
983
+ try {
984
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
985
+ }
986
+ catch (e) {
987
+ throw new Error(`${file} is not valid JSON (${e instanceof Error ? e.message : String(e)}). omac parses ` +
988
+ `profiles strictly; load-lock refuses to overwrite it — fix or remove the file, then re-run install`);
989
+ }
990
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
991
+ throw new Error(`${file} is not a JSON object; refusing to overwrite`);
992
+ }
993
+ return parsed;
994
+ }
995
+ /** The profile sub-object `key`, created when absent; refuses non-objects. */
996
+ function omacSection(profile, key) {
997
+ const v = profile[key];
998
+ if (v === undefined) {
999
+ const section = {};
1000
+ profile[key] = section;
1001
+ return section;
1002
+ }
1003
+ if (typeof v !== "object" || v === null || Array.isArray(v)) {
1004
+ throw new Error(`omac profile: "${key}" is not an object; refusing to overwrite`);
1005
+ }
1006
+ return v;
1007
+ }
1008
+ /**
1009
+ * Merge string entries into section[key] (created only when something is
1010
+ * added, so we never write empty arrays). Returns the entries actually
1011
+ * added; refuses non-string-array values instead of mangling them.
1012
+ */
1013
+ function omacMergeList(section, key, entries) {
1014
+ const v = section[key];
1015
+ let list;
1016
+ if (v === undefined)
1017
+ list = [];
1018
+ else if (Array.isArray(v) && v.every((x) => typeof x === "string"))
1019
+ list = [...v];
1020
+ else
1021
+ throw new Error(`omac profile: "${key}" is not an array of strings; refusing to overwrite`);
1022
+ const added = entries.filter((e) => !list.includes(e));
1023
+ if (added.length)
1024
+ section[key] = [...list, ...added];
1025
+ return added;
1026
+ }
1027
+ /**
1028
+ * The installed package tree a sandboxed CLI must be able to *read*.
1029
+ * Live-verified gotcha: a global bun/npm install grants the bin dir, but
1030
+ * the launcher resolves the dist tree under `.../node_modules/@aron98/
1031
+ * load-lock` — without a read grant the CLI dies with ERR_MODULE_NOT_FOUND
1032
+ * before ever reaching the socket. Returns the realpath of the package
1033
+ * root, or null when load-lock lives in a repo checkout / worktree (the
1034
+ * nearest `.git`): there the agent's omac workdir grant already covers
1035
+ * it, and a read grant would be noise.
1036
+ */
1037
+ function omacReadGrant(P) {
1038
+ let root = P.root;
1039
+ try {
1040
+ root = fs.realpathSync(root);
1041
+ }
1042
+ catch {
1043
+ /* keep the unresolved path — omac grants are not existence-filtered */
1044
+ }
1045
+ for (let dir = root;;) {
1046
+ if (fs.existsSync(path.join(dir, ".git")))
1047
+ return null; // repo checkout / git worktree
1048
+ const parent = path.dirname(dir);
1049
+ if (parent === dir)
1050
+ break;
1051
+ dir = parent;
1052
+ }
1053
+ return root;
1054
+ }
1055
+ /**
1056
+ * Remove our entries (match-own-entries style: exact current state dir,
1057
+ * the "LOAD_LOCK_*" prefix entry, and read entries pointing at the
1058
+ * current package root or at a global `node_modules/@aron98/load-lock`
1059
+ * tree — the shape the installer writes). Keys whose lists we emptied are
1060
+ * dropped; sections that become empty are dropped; the file is removed
1061
+ * (and dirs pruned) when nothing user-owned remains.
1062
+ */
1063
+ function uninstallOmac() {
1064
+ const actions = [];
1065
+ const file = omacProfileFile();
1066
+ const profile = omacReadProfile(file);
1067
+ if (!profile)
1068
+ return actions; // nothing installed; uninstall never creates anything
1069
+ const stateDir = resolveStateDir(process.platform, process.env);
1070
+ const readTree = omacReadGrant(pkgPaths());
1071
+ const globalPkgSuffix = path.join("node_modules", PKG_NAME);
1072
+ let changed = false;
1073
+ const drop = (sectionKey, key, isOurs) => {
1074
+ const section = profile[sectionKey];
1075
+ if (!section || typeof section !== "object" || Array.isArray(section))
1076
+ return;
1077
+ const list = section[key];
1078
+ if (!Array.isArray(list) || !list.every((x) => typeof x === "string"))
1079
+ return;
1080
+ const kept = list.filter((e) => !isOurs(e));
1081
+ if (kept.length === list.length)
1082
+ return;
1083
+ changed = true;
1084
+ if (kept.length)
1085
+ section[key] = kept;
1086
+ else
1087
+ delete section[key];
1088
+ };
1089
+ drop("filesystem", "allow_unix_dir", (e) => e === stateDir);
1090
+ drop("filesystem", "read", (e) => (readTree !== null && e === readTree) || e.endsWith(globalPkgSuffix));
1091
+ drop("environment", "allow_vars", (e) => e === OMAC_LL_ENV_ENTRY);
1092
+ // prune sections our removals emptied (never anything the user still has)
1093
+ for (const key of ["filesystem", "environment"]) {
1094
+ const section = profile[key];
1095
+ if (section && typeof section === "object" && !Array.isArray(section) && Object.keys(section).length === 0) {
1096
+ delete profile[key];
1097
+ }
1098
+ }
1099
+ if (!changed) {
1100
+ actions.push(`no load-lock grants found in ${file}`);
1101
+ return actions;
1102
+ }
1103
+ if (Object.keys(profile).length === 0) {
1104
+ fs.rmSync(file);
1105
+ actions.push(`removed ${file} (nothing else was in it)`);
1106
+ pruneEmptyDirs(path.dirname(file), path.join(configHome(), "omac"));
1107
+ }
1108
+ else {
1109
+ writeJson(file, profile);
1110
+ actions.push(`removed load-lock grants from ${file}`);
1111
+ }
1112
+ return actions;
1113
+ }
1114
+ function installOmac(opts, P) {
1115
+ const actions = [];
1116
+ const file = omacProfileFile();
1117
+ const existed = fs.existsSync(file);
1118
+ const profile = omacReadProfile(file) ?? {};
1119
+ const stateDir = resolveStateDir(process.platform, process.env);
1120
+ const grants = [];
1121
+ const fsSection = omacSection(profile, "filesystem");
1122
+ grants.push(...omacMergeList(fsSection, "allow_unix_dir", [stateDir]));
1123
+ const readTree = omacReadGrant(P);
1124
+ if (readTree)
1125
+ grants.push(...omacMergeList(fsSection, "read", [readTree]));
1126
+ const envSection = omacSection(profile, "environment");
1127
+ grants.push(...omacMergeList(envSection, "allow_vars", [OMAC_LL_ENV_ENTRY]));
1128
+ // merge-only: never rewrite the file when nothing changed (idempotent
1129
+ // re-install leaves the user's bytes untouched)
1130
+ if (grants.length || !existed)
1131
+ writeJson(file, profile);
1132
+ if (!existed)
1133
+ actions.push(`created ${file} (omac's user-global sandbox profile)`);
1134
+ else if (grants.length)
1135
+ actions.push(`grants merged into ${file}`);
1136
+ else
1137
+ actions.push(`all grants already present in ${file} — nothing to do`);
1138
+ for (const g of grants) {
1139
+ if (g === OMAC_LL_ENV_ENTRY) {
1140
+ actions.push(`environment.allow_vars += "${OMAC_LL_ENV_ENTRY}" (prefix pattern; passes all four LOAD_LOCK_* vars through the sandbox env filter)`);
1141
+ }
1142
+ else if (g === stateDir) {
1143
+ actions.push(`filesystem.allow_unix_dir += ${stateDir} (AF_UNIX connect to the host daemon socket + read/write on the state dir)`);
1144
+ }
1145
+ else {
1146
+ actions.push(`filesystem.read += ${g} (installed package tree — the bin-dir grant alone is not enough; without this the CLI dies with ERR_MODULE_NOT_FOUND before reaching the socket)`);
1147
+ }
1148
+ }
1149
+ if (!readTree) {
1150
+ actions.push(`skipped filesystem.read: load-lock lives in a repo checkout (${P.root}) — the omac workdir grant already covers it`);
1151
+ }
1152
+ actions.push("set the values in your harness env (the grants only allow the names through): " +
1153
+ "LOAD_LOCK_NO_SPAWN=1 (client-only; the daemon must run on the host, e.g. load-lock service start), " +
1154
+ "LOAD_LOCK_LABEL_PREFIX=omac (dashboard shows holders as omac:<label>); " +
1155
+ "LOAD_LOCK_SOCKET only if the default <state dir>/daemon.sock is not what the sandbox sees");
1156
+ actions.push("label prefixes need load-lock >= 1.2.0 on BOTH ends: a daemon started before v1.2.0 " +
1157
+ "silently strips the unknown labelPrefix field — upgrade and restart the daemon (load-lock service stop && load-lock service start)");
1158
+ actions.push("verified live against omac 0.11.0 (bubblewrap + Landlock): sandboxed client -> host daemon " +
1159
+ "round-trip, prefixed labels, LOAD_LOCK_NO_SPAWN error contract");
1160
+ return actions;
1161
+ }
951
1162
  // -------------------------------------------------------------------- public
952
1163
  export async function installCommand(opts) {
953
1164
  const P = pkgPaths(opts.binDir);
@@ -956,7 +1167,7 @@ export async function installCommand(opts) {
956
1167
  }
957
1168
  const harness = opts.harness;
958
1169
  if (!harness)
959
- throw new Error("install: --harness <opencode|claude-code|codex|cursor|gemini-cli|copilot-cli|cline|roo-code|goose|generic> is required");
1170
+ throw new Error("install: --harness <opencode|claude-code|codex|cursor|gemini-cli|copilot-cli|cline|roo-code|goose|omac|generic> is required");
960
1171
  let actions;
961
1172
  switch (harness) {
962
1173
  case "opencode":
@@ -992,6 +1203,9 @@ export async function installCommand(opts) {
992
1203
  case "goose":
993
1204
  actions = installGoose(opts, P);
994
1205
  break;
1206
+ case "omac":
1207
+ actions = installOmac(opts, P);
1208
+ break;
995
1209
  case "generic":
996
1210
  case "all":
997
1211
  default:
@@ -1044,6 +1258,9 @@ export async function uninstallCommand(opts) {
1044
1258
  case "goose":
1045
1259
  actions = uninstallGoose();
1046
1260
  break;
1261
+ case "omac":
1262
+ actions = uninstallOmac();
1263
+ break;
1047
1264
  default:
1048
1265
  throw new Error(`uninstall: unknown harness "${opts.harness}"`);
1049
1266
  }