@lawaty/lacode 0.1.0 → 0.1.2

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/dist/lacode.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  // bin/lacode
4
4
  import { fileURLToPath } from "node:url";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { spawnSync as spawnSync2 } from "node:child_process";
5
7
 
6
8
  // src/tunnel/index.ts
7
9
  import { spawnSync } from "node:child_process";
@@ -19,6 +21,7 @@ function resolveTunnel(env = process.env) {
19
21
  const port = Number(env.OC_TUNNEL_PORT);
20
22
  return {
21
23
  user: env.OC_LOCAL_USER ?? "",
24
+ host: env.OC_LOCAL_HOST ?? "",
22
25
  port: Number.isFinite(port) && port > 0 ? port : 10022,
23
26
  key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
24
27
  connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
@@ -41,6 +44,15 @@ var safeText = (raw, fallback, max) => {
41
44
  var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
42
45
  var safeMessage = (raw) => safeText(raw ?? "", "", 400);
43
46
  var FIELD_SEPARATOR = "\n";
47
+ function parsePayload(raw) {
48
+ const [title, message, urgency, icon] = raw.split(FIELD_SEPARATOR);
49
+ return {
50
+ title: safeTitle(title),
51
+ message: safeMessage(message),
52
+ urgency: normalizeUrgency(urgency),
53
+ icon: normalizeIcon(icon)
54
+ };
55
+ }
44
56
  var encodePayload = (request) => [
45
57
  safeTitle(request.title),
46
58
  safeMessage(request.message),
@@ -59,6 +71,14 @@ var SSH_OPTS = [
59
71
  "-o",
60
72
  "StrictHostKeyChecking=accept-new"
61
73
  ];
74
+ var CONTROL_OPTS = [
75
+ "-o",
76
+ "ControlMaster=auto",
77
+ "-o",
78
+ "ControlPersist=300",
79
+ "-o",
80
+ `ControlPath=${join(homedir(), ".ssh", "lacode-cm-%C")}`
81
+ ];
62
82
  var run = (command2, args, timeout) => {
63
83
  try {
64
84
  const result = spawnSync(command2, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
@@ -81,37 +101,64 @@ var have = (command2) => {
81
101
  return false;
82
102
  }
83
103
  };
84
- function sendLocal(request) {
85
- if (!have("notify-send")) return { ok: false, detail: "notify-send is not installed", transport: "local" };
86
- const args = [
87
- safeTitle(request.title),
88
- safeMessage(request.message),
89
- `--urgency=${normalizeUrgency(request.urgency)}`,
90
- `--icon=${normalizeIcon(request.icon)}`
91
- ];
92
- const result = run("notify-send", args, 5e3);
104
+ var CHANNEL_BINARY = {
105
+ "notify-send": "notify-send",
106
+ osascript: "osascript",
107
+ "terminal-notifier": "terminal-notifier"
108
+ };
109
+ var DARWIN_SCRIPT = [
110
+ "on run argv",
111
+ " display notification (item 3 of argv) with title (item 2 of argv)",
112
+ "end run"
113
+ ].join("\n");
114
+ var DARWIN_ARGV_MARKER = "lacode";
115
+ function localChannel(platform = process.platform) {
116
+ if (platform === "darwin") {
117
+ if (have(CHANNEL_BINARY.osascript)) return "osascript";
118
+ if (have(CHANNEL_BINARY["terminal-notifier"])) return "terminal-notifier";
119
+ return "none";
120
+ }
121
+ return have(CHANNEL_BINARY["notify-send"]) ? "notify-send" : "none";
122
+ }
123
+ var NO_CHANNEL = {
124
+ linux: "notify-send is not installed",
125
+ darwin: "neither osascript nor terminal-notifier is installed"
126
+ };
127
+ function sendLocal(request, platform = process.platform) {
128
+ const channel = localChannel(platform);
129
+ if (channel === "none") {
130
+ return { ok: false, detail: NO_CHANNEL[platform] ?? `no local notification channel on ${platform}`, transport: "local" };
131
+ }
132
+ const title = safeTitle(request.title);
133
+ const body = safeMessage(request.message);
134
+ const args = channel === "osascript" ? ["-e", DARWIN_SCRIPT, DARWIN_ARGV_MARKER, title, body] : channel === "terminal-notifier" ? ["-title", title, "-message", body, "-appName", "LaCode", "-priority", normalizeUrgency(request.urgency)] : (
135
+ // Byte-identical to the notify-send call this package has always made.
136
+ [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
137
+ );
138
+ const result = run(CHANNEL_BINARY[channel], args, 5e3);
93
139
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
94
140
  }
95
141
  function sendRemote(request, tunnel) {
96
142
  if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
97
- const args = [
98
- ...SSH_OPTS,
99
- "-o",
100
- `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`,
101
- "-i",
102
- tunnel.key,
103
- "-p",
104
- String(tunnel.port),
105
- `${tunnel.user}@localhost`,
106
- "--",
107
- encodePayload(request)
108
- ];
109
- const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
143
+ const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
144
+ if (tunnel.host) {
145
+ const result2 = run(
146
+ "ssh",
147
+ [...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
148
+ tunnel.connectTimeoutMs + 2e3
149
+ );
150
+ return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
151
+ }
152
+ const result = run(
153
+ "ssh",
154
+ [...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
155
+ tunnel.connectTimeoutMs + 2e3
156
+ );
110
157
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
111
158
  }
112
- function notify(request, env = process.env) {
159
+ function notify(request, env = process.env, platform = process.platform) {
113
160
  try {
114
- return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request);
161
+ return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
115
162
  } catch (error) {
116
163
  return { ok: false, detail: String(error), transport: "none" };
117
164
  }
@@ -127,13 +174,17 @@ function forward(host, argv2, options = {}) {
127
174
  stdout: result.stdout
128
175
  };
129
176
  }
130
- function describe(env = process.env) {
177
+ function describe(env = process.env, platform = process.platform) {
131
178
  const tunnel = resolveTunnel(env);
132
179
  return {
133
180
  running: isRemote(env) ? "remote (over ssh)" : "local",
134
181
  user: tunnel.user || "(unset)",
182
+ host: tunnel.host || "(unset)",
135
183
  port: tunnel.port,
136
184
  key: tunnel.key,
185
+ channel: localChannel(platform),
186
+ // Still exactly what it always meant -- is the notify-send binary present --
187
+ // rather than a second opinion about which channel runs.
137
188
  notifySend: have("notify-send")
138
189
  };
139
190
  }
@@ -144,8 +195,8 @@ if (!process.features?.typescript) {
144
195
  console.error("lacode: this node cannot load TypeScript sources; node >= 22.18 is required.");
145
196
  process.exit(1);
146
197
  }
147
- const { spawnSync: spawnSync2 } = await import("node:child_process");
148
- const result = spawnSync2(
198
+ const { spawnSync: spawnSync3 } = await import("node:child_process");
199
+ const result = spawnSync3(
149
200
  process.execPath,
150
201
  ["--experimental-strip-types", "--no-warnings", fileURLToPath(import.meta.url), ...process.argv.slice(2)],
151
202
  { stdio: "inherit", env: { ...process.env, LACODE_STRIP_TYPECHECKED: "1" } }
@@ -212,7 +263,44 @@ switch (command) {
212
263
  if (info.running.startsWith("remote")) {
213
264
  if (info.user === "(unset)") console.log("\nOC_LOCAL_USER is unset: the hop cannot name the desktop user, and guessing with `id -un` is wrong on most remote accounts.");
214
265
  console.log("Set OC_LOCAL_USER, OC_TUNNEL_PORT and OC_NOTIFY_KEY, or point OC_NOTIFY_HELPER at a helper that knows them.");
266
+ } else if (info.channel === "none") {
267
+ console.log("\nNo local notification channel: `lacode notify` on this machine has nothing to deliver with, and every send will be reported as failed rather than shown.");
268
+ }
269
+ process.exit(0);
270
+ break;
271
+ }
272
+ case "receive": {
273
+ const request = parsePayload(process.env.SSH_ORIGINAL_COMMAND ?? "");
274
+ const result = sendLocal(request);
275
+ if (!result.ok) console.error(`lacode receive: ${result.detail ?? "not delivered"}`);
276
+ process.exit(result.ok ? 0 : 1);
277
+ break;
278
+ }
279
+ case "tunnel": {
280
+ const sub = argv.shift();
281
+ if (sub !== "setup") {
282
+ console.error("lacode tunnel: usage: lacode tunnel setup");
283
+ process.exit(2);
284
+ }
285
+ const { user, host, key } = resolveTunnel();
286
+ const pub = `${key}.pub`;
287
+ if (!existsSync(key)) {
288
+ const gen = spawnSync2("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", "lacode-notify", "-f", key], { stdio: ["ignore", "ignore", "inherit"] });
289
+ if (gen.status !== 0) {
290
+ console.error("lacode tunnel setup: ssh-keygen failed; create the key by hand and re-run.");
291
+ process.exit(1);
292
+ }
293
+ console.log(`Generated ${key}`);
215
294
  }
295
+ const pubkey = existsSync(pub) ? readFileSync(pub, "utf8").trim() : "(public key missing)";
296
+ const self = process.argv[1] ?? "lacode";
297
+ console.log("\nOn the REMOTE host (where opencode runs), export:");
298
+ console.log(` OC_LOCAL_USER=${user || "<desktop-user>"}`);
299
+ console.log(` OC_LOCAL_HOST=${host || "<desktop-host>"}`);
300
+ console.log(` OC_NOTIFY_KEY=${key}`);
301
+ console.log("\nAdd ONE line to the DESKTOP's ~/.ssh/authorized_keys (this is the trust boundary):");
302
+ console.log(` restrict,command="${self} receive" ${pubkey}`);
303
+ console.log("\nDirect ssh + ControlPersist: no reverse forward, no systemd unit, no port, nothing to sync.");
216
304
  process.exit(0);
217
305
  break;
218
306
  }
package/dist/pool.js CHANGED
@@ -10,12 +10,13 @@ var CATALOG_TTL_MS = 10 * 60 * 1e3;
10
10
  var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
11
11
  var STUCK_IDLE_MS = 3 * 60 * 1e3;
12
12
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
13
+ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
13
14
  var MAX_WEIGHT = 100;
14
15
  var MAX_SLOTS = 4;
15
16
  var COOLDOWN_BASE_MS = 60 * 1e3;
16
17
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
17
18
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
18
- var BASES = ["explore-fast", "implement-fast", "context-manager"];
19
+ var BASES = ["explore", "implement", "context"];
19
20
  var findPackageRoot = (start) => {
20
21
  let dir = start;
21
22
  for (let up = 0; up < 6; up++) {
@@ -141,6 +142,7 @@ function readWeight(entry, at, model, problems) {
141
142
  }
142
143
  return weight;
143
144
  }
145
+ var UNAVAILABLE_PATTERN = /endpoint\s+is\s+unavailable/i;
144
146
  var LIMIT_PATTERNS = [
145
147
  /rate[\s_-]?limit/i,
146
148
  /usage limit/i,
@@ -148,9 +150,11 @@ var LIMIT_PATTERNS = [
148
150
  /\b429\b/,
149
151
  /quota/i,
150
152
  /freelimiterror/i,
151
- /overloaded/i
153
+ /overloaded/i,
154
+ UNAVAILABLE_PATTERN
152
155
  ];
153
156
  var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text));
157
+ var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
154
158
  var statedCooldown = (text) => {
155
159
  const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
156
160
  if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
@@ -332,6 +336,22 @@ function recordStrike(s, model, reason, stated) {
332
336
  }
333
337
  return strike;
334
338
  }
339
+ function coolUnavailable(s, model) {
340
+ if (!s.dirReady) return;
341
+ const now = Date.now();
342
+ const prior = s.cooling.get(model);
343
+ const until = now + UNAVAILABLE_COOLDOWN_MS;
344
+ if (prior && prior.until > until) return prior;
345
+ const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
346
+ s.cooling.set(model, strike);
347
+ const path = join(s.dir, limitFile(model));
348
+ try {
349
+ writeFileSync(`${path}.tmp`, JSON.stringify(strike));
350
+ renameSync(`${path}.tmp`, path);
351
+ } catch {
352
+ }
353
+ return strike;
354
+ }
335
355
  function refreshCatalog(s, serverUrl) {
336
356
  if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
337
357
  s.catalogAt = Date.now();
@@ -448,6 +468,7 @@ export {
448
468
  PRESETS_DIR,
449
469
  STUCK_IDLE_MS,
450
470
  STUCK_MIN_AGE_MS,
471
+ UNAVAILABLE_COOLDOWN_MS,
451
472
  USER_CONFIG_DIR,
452
473
  USER_MODELS_PATH,
453
474
  acquire,
@@ -455,6 +476,7 @@ export {
455
476
  bindChild,
456
477
  claimKey,
457
478
  coolProvider,
479
+ coolUnavailable,
458
480
  ensureSlots,
459
481
  ensureUserConfigDir,
460
482
  findStuck,
@@ -462,6 +484,7 @@ export {
462
484
  init,
463
485
  isAvailable,
464
486
  isLimitText,
487
+ isUnavailableText,
465
488
  noteActivity,
466
489
  parseSlots,
467
490
  pickSlot,
package/dist/tunnel.js CHANGED
@@ -14,6 +14,7 @@ function resolveTunnel(env = process.env) {
14
14
  const port = Number(env.OC_TUNNEL_PORT);
15
15
  return {
16
16
  user: env.OC_LOCAL_USER ?? "",
17
+ host: env.OC_LOCAL_HOST ?? "",
17
18
  port: Number.isFinite(port) && port > 0 ? port : 10022,
18
19
  key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
19
20
  connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
@@ -36,6 +37,15 @@ var safeText = (raw, fallback, max) => {
36
37
  var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
37
38
  var safeMessage = (raw) => safeText(raw ?? "", "", 400);
38
39
  var FIELD_SEPARATOR = "\n";
40
+ function parsePayload(raw) {
41
+ const [title, message, urgency, icon] = raw.split(FIELD_SEPARATOR);
42
+ return {
43
+ title: safeTitle(title),
44
+ message: safeMessage(message),
45
+ urgency: normalizeUrgency(urgency),
46
+ icon: normalizeIcon(icon)
47
+ };
48
+ }
39
49
  var encodePayload = (request) => [
40
50
  safeTitle(request.title),
41
51
  safeMessage(request.message),
@@ -54,6 +64,14 @@ var SSH_OPTS = [
54
64
  "-o",
55
65
  "StrictHostKeyChecking=accept-new"
56
66
  ];
67
+ var CONTROL_OPTS = [
68
+ "-o",
69
+ "ControlMaster=auto",
70
+ "-o",
71
+ "ControlPersist=300",
72
+ "-o",
73
+ `ControlPath=${join(homedir(), ".ssh", "lacode-cm-%C")}`
74
+ ];
57
75
  var run = (command, args, timeout) => {
58
76
  try {
59
77
  const result = spawnSync(command, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
@@ -76,37 +94,64 @@ var have = (command) => {
76
94
  return false;
77
95
  }
78
96
  };
79
- function sendLocal(request) {
80
- if (!have("notify-send")) return { ok: false, detail: "notify-send is not installed", transport: "local" };
81
- const args = [
82
- safeTitle(request.title),
83
- safeMessage(request.message),
84
- `--urgency=${normalizeUrgency(request.urgency)}`,
85
- `--icon=${normalizeIcon(request.icon)}`
86
- ];
87
- const result = run("notify-send", args, 5e3);
97
+ var CHANNEL_BINARY = {
98
+ "notify-send": "notify-send",
99
+ osascript: "osascript",
100
+ "terminal-notifier": "terminal-notifier"
101
+ };
102
+ var DARWIN_SCRIPT = [
103
+ "on run argv",
104
+ " display notification (item 3 of argv) with title (item 2 of argv)",
105
+ "end run"
106
+ ].join("\n");
107
+ var DARWIN_ARGV_MARKER = "lacode";
108
+ function localChannel(platform = process.platform) {
109
+ if (platform === "darwin") {
110
+ if (have(CHANNEL_BINARY.osascript)) return "osascript";
111
+ if (have(CHANNEL_BINARY["terminal-notifier"])) return "terminal-notifier";
112
+ return "none";
113
+ }
114
+ return have(CHANNEL_BINARY["notify-send"]) ? "notify-send" : "none";
115
+ }
116
+ var NO_CHANNEL = {
117
+ linux: "notify-send is not installed",
118
+ darwin: "neither osascript nor terminal-notifier is installed"
119
+ };
120
+ function sendLocal(request, platform = process.platform) {
121
+ const channel = localChannel(platform);
122
+ if (channel === "none") {
123
+ return { ok: false, detail: NO_CHANNEL[platform] ?? `no local notification channel on ${platform}`, transport: "local" };
124
+ }
125
+ const title = safeTitle(request.title);
126
+ const body = safeMessage(request.message);
127
+ const args = channel === "osascript" ? ["-e", DARWIN_SCRIPT, DARWIN_ARGV_MARKER, title, body] : channel === "terminal-notifier" ? ["-title", title, "-message", body, "-appName", "LaCode", "-priority", normalizeUrgency(request.urgency)] : (
128
+ // Byte-identical to the notify-send call this package has always made.
129
+ [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
130
+ );
131
+ const result = run(CHANNEL_BINARY[channel], args, 5e3);
88
132
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
89
133
  }
90
134
  function sendRemote(request, tunnel) {
91
135
  if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
92
- const args = [
93
- ...SSH_OPTS,
94
- "-o",
95
- `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`,
96
- "-i",
97
- tunnel.key,
98
- "-p",
99
- String(tunnel.port),
100
- `${tunnel.user}@localhost`,
101
- "--",
102
- encodePayload(request)
103
- ];
104
- const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
136
+ const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
137
+ if (tunnel.host) {
138
+ const result2 = run(
139
+ "ssh",
140
+ [...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
141
+ tunnel.connectTimeoutMs + 2e3
142
+ );
143
+ return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
144
+ }
145
+ const result = run(
146
+ "ssh",
147
+ [...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
148
+ tunnel.connectTimeoutMs + 2e3
149
+ );
105
150
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
106
151
  }
107
- function notify(request, env = process.env) {
152
+ function notify(request, env = process.env, platform = process.platform) {
108
153
  try {
109
- return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request);
154
+ return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
110
155
  } catch (error) {
111
156
  return { ok: false, detail: String(error), transport: "none" };
112
157
  }
@@ -122,13 +167,17 @@ function forward(host, argv, options = {}) {
122
167
  stdout: result.stdout
123
168
  };
124
169
  }
125
- function describe(env = process.env) {
170
+ function describe(env = process.env, platform = process.platform) {
126
171
  const tunnel = resolveTunnel(env);
127
172
  return {
128
173
  running: isRemote(env) ? "remote (over ssh)" : "local",
129
174
  user: tunnel.user || "(unset)",
175
+ host: tunnel.host || "(unset)",
130
176
  port: tunnel.port,
131
177
  key: tunnel.key,
178
+ channel: localChannel(platform),
179
+ // Still exactly what it always meant -- is the notify-send binary present --
180
+ // rather than a second opinion about which channel runs.
132
181
  notifySend: have("notify-send")
133
182
  };
134
183
  }
@@ -139,9 +188,11 @@ export {
139
188
  encodePayload,
140
189
  forward,
141
190
  isRemote,
191
+ localChannel,
142
192
  normalizeIcon,
143
193
  normalizeUrgency,
144
194
  notify,
195
+ parsePayload,
145
196
  resolveTunnel,
146
197
  safeMessage,
147
198
  safeTitle,
package/docs/CONTEXT.md CHANGED
@@ -69,14 +69,14 @@ something has gone wrong.
69
69
 
70
70
  ## How it stays current
71
71
 
72
- The `context-manager` agent maintains the map. It runs in two ways: on its own,
72
+ The `context` agent maintains the map. It runs in two ways: on its own,
73
73
  after a session that changed something, and on demand through the three
74
74
  commands — both of which go through the same agent, so they behave the same way.
75
75
 
76
76
  ### Automatically
77
77
 
78
78
  After a **root** session goes idle, and only if that session edited a file
79
- inside the project, LaCode starts a `context-manager` run:
79
+ inside the project, LaCode starts a `context` run:
80
80
 
81
81
  1. It collects the paths the session touched, dropping anything generated,
82
82
  vendored or outside the project (`node_modules`, `dist`, `coverage`,
@@ -123,7 +123,7 @@ as you wrote it — the same merge-without-clobber rule the agents follow.
123
123
 
124
124
  ## One writer
125
125
 
126
- Only `context-manager` writes the map. Every other agent is told never to edit
126
+ Only `context` writes the map. Every other agent is told never to edit
127
127
  it, and to say in its final report if its work revealed something that belongs
128
128
  in there. The cartographer, in turn, is denied permission to write anything
129
129
  outside the map — not by convention, but by an `edit` rule that allows
@@ -199,7 +199,7 @@ thing to check is that permission rule, not the map's content.
199
199
  | `auto update finished without changing the map` | Nothing landed. Either nothing needed saying, or the cartographer could not write. |
200
200
  | `auto update failed: …` | The run errored. The paths it was given are queued again for the next window, so the work is not lost. |
201
201
  | `context map is not writable: …` | Your config's one-writer rule does not match the path opencode evaluates. Nothing will update until you fix it. |
202
- | `context map has more than one writer: …` | An agent other than `context-manager` would be granted the map. The offending agent and the winning rule are named. |
202
+ | `context map has more than one writer: …` | An agent other than `context` would be granted the map. The offending agent and the winning rule are named. |
203
203
 
204
204
  ## Reference
205
205
 
package/docs/TUNNEL.md CHANGED
@@ -19,7 +19,15 @@ gets it back.
19
19
  └──────────────────┘ └────────────────────────┘
20
20
  ```
21
21
 
22
- Two things are load-bearing here.
22
+ **Direct ssh (the default).** Set `OC_LOCAL_HOST` and the hop is a plain `ssh`
23
+ to the desktop with `ControlMaster=auto` + `ControlPersist`, so one warm
24
+ connection carries every notification. There is no reverse forward, no listening
25
+ port, no systemd unit and nothing to sync: the plugin owns the transport. The
26
+ desktop needs one `authorized_keys` line, which `lacode tunnel setup` prints. The
27
+ reverse-forward description below is the legacy path, used only when
28
+ `OC_LOCAL_HOST` is unset.
29
+
30
+ **Reverse forward (legacy).** Two things are load-bearing here.
23
31
 
24
32
  **The reverse forward.** The remote opens an SSH connection to *your* machine
25
33
  and asks sshd on the desktop to listen on a port. A notification is then an ssh
@@ -46,7 +54,9 @@ spaces, quotes or `*` survives intact.
46
54
  ## Sending
47
55
 
48
56
  The CLI picks the transport by where it is running — `SSH_CLIENT` or `SSH_TTY`
49
- means remote, so it forwards; otherwise it calls `notify-send` directly.
57
+ means remote, so it forwards; otherwise it delivers locally through whichever
58
+ desktop notifier this platform has: `notify-send` everywhere else, `osascript` on
59
+ macOS, with `terminal-notifier` as the macOS fallback when `osascript` is missing.
50
60
 
51
61
  ```bash
52
62
  lacode notify "build finished" "lapode: 4 files changed" normal dialog-information
@@ -55,19 +65,25 @@ lacode forward web-01 -- systemctl --user status oc-tunnel
55
65
  lacode doctor
56
66
  ```
57
67
 
58
- `notify` is fire-and-forget by design. An unreachable desktop, a missing key, a
59
- missing `notify-send` — all of them print a warning on stderr and exit 0. A
68
+ `notify` is fire-and-forget by design. An unreachable desktop, a missing key, no
69
+ notifier installed at all — all of them print a warning on stderr and exit 0. A
60
70
  notification must never be able to fail the process that tried to send one; an
61
71
  agent must not go down because a laptop went to sleep. `forward` is the
62
72
  exception: it exits with the remote's status, because its caller wants to know.
63
73
 
74
+ On macOS the text is passed to `osascript` as arguments to an `on run argv`
75
+ handler, never spliced into the script source — a session title is not trusted
76
+ input. `lacode doctor` prints the channel that would actually run, so it is
77
+ useful on a Mac rather than reporting only whether a Linux binary is installed.
78
+
64
79
  `lacode doctor` prints what the environment resolves to and never gates on it.
65
80
 
66
81
  ## Configuration
67
82
 
68
83
  | Variable | Default | Meaning |
69
84
  | --- | --- | --- |
70
- | `OC_LOCAL_USER` | *(unset)* | The desktop user the tunnel authenticates as. **Required for the remote hop.** |
85
+ | `OC_LOCAL_USER` | *(unset)* | The desktop user the hop authenticates as. **Required for the remote hop.** |
86
+ | `OC_LOCAL_HOST` | *(unset)* | Desktop hostname for the **direct ssh** hop. Set it and the reverse forward/port/systemd unit are not used at all. |
71
87
  | `OC_TUNNEL_PORT` | `10022` | Reverse-forward listener port on the desktop. Give each remote host its own. |
72
88
  | `OC_NOTIFY_KEY` | `~/.ssh/lacode_notify` | The dedicated key. Never a personal key. |
73
89
  | `OC_CONNECT_TIMEOUT_MS` | `5000` | Ceiling on the hop. |
@@ -80,10 +96,12 @@ nobody's desktop. Guessing the desktop user is worse than failing.
80
96
  ## Setting the tunnel up on the desktop
81
97
 
82
98
  The receiver runs on the machine with the desktop, as an `authorized_keys`
83
- forced command restricted to that one script:
99
+ forced command restricted to that one script. `$HOME` must be the expanded
100
+ absolute path — a `~` is not expanded there, and an unexpanded one produces an
101
+ `authorized_keys` entry that names a file that does not exist:
84
102
 
85
103
  ```
86
- restrict,command="/home/you/.local/bin/lacode-notify-receiver" ssh-ed25519 AAAA… oc-notify tunnel auth
104
+ restrict,command="/home/yourname/.local/bin/lacode-notify-receiver" ssh-ed25519 AAAA… oc-notify tunnel auth
87
105
  ```
88
106
 
89
107
  `restrict` is doing the security work: no shell, no PTY, no agent forwarding, no
@@ -96,12 +114,36 @@ The receiver must satisfy this contract:
96
114
  argument list — it is attacker-controlled, because it is whatever the remote
97
115
  user typed;
98
116
  3. split it on newlines into title / message / urgency / icon, each with a
99
- default;
117
+ default — **using only constructs bash 3.2 has**, not just bash 4+;
100
118
  4. whitelist `urgency` against `low|normal|critical` and `icon` against the six
101
119
  supported names, both of which become command-line flags;
102
120
  5. resolve `DISPLAY` / `DBUS_SESSION_BUS_ADDRESS` for the session it is running
103
121
  in, then `exec notify-send "$TITLE" "$MSG" --urgency=… --icon=…`.
104
122
 
123
+ Point 3 is not a detail. `mapfile`/`readarray` are bash 4.0+, and where they are
124
+ missing — macOS ships bash 3.2, as do some minimal Linux images — the array is
125
+ simply never built, every field falls back to its default, and the desktop gets
126
+ a notification titled "LaCode" with an empty body. No error is printed anywhere
127
+ that anyone will read, because the receiver's stderr goes to whoever happened to
128
+ open the ssh connection. A `while IFS= read -r` loop is equivalent and works
129
+ everywhere; `bin/oc-notify-receiver` uses one.
130
+
131
+ ### This half of the hop is Linux
132
+
133
+ The receiver delivers through `notify-send`, and the forward that reaches it is
134
+ a systemd `--user` unit (below). Both are Linux, so **the desktop half of this
135
+ tunnel does not exist on macOS**, and that is by design rather than a gap.
136
+
137
+ On a Mac you do not need it: `lacode notify` run locally picks `osascript` (or
138
+ `terminal-notifier`) and shows the notification itself, with no tunnel, no
139
+ `authorized_keys` entry and no systemd unit involved. The tunnel exists for the
140
+ case opencode runs *somewhere else*, and a Mac is not going to be running it.
141
+
142
+ If you point the receiver at a machine with no `notify-send` anyway, it exits
143
+ non-zero and says so, naming `lacode notify` as the path that does work there.
144
+ `bin/oc-sync` refuses the same machine for the same reason, and names each
145
+ missing prerequisite instead of failing somewhere less obvious.
146
+
105
147
  > **Note.** The published package ships the built CLI (`dist/lacode.js`, linked
106
148
  > as `lacode`) and the tunnel behind it (`dist/tunnel.js`, importable as
107
149
  > `@lawaty/lacode/tunnel`), not a receiver script. The receiver is the