@lawaty/lacode 0.9.0 → 0.11.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/dist/lacode.js CHANGED
@@ -2,12 +2,22 @@
2
2
 
3
3
  // bin/lacode
4
4
  import { fileURLToPath as fileURLToPath3 } from "node:url";
5
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
5
+ import { existsSync as existsSync4, readFileSync as readFileSync7 } from "node:fs";
6
6
  import { spawnSync as spawnSync3 } from "node:child_process";
7
7
 
8
8
  // src/tunnel/index.ts
9
9
  import { spawn, spawnSync } from "node:child_process";
10
- import { existsSync } from "node:fs";
10
+ import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "node:fs";
11
+ import { homedir as homedir3 } from "node:os";
12
+ import { join as join4 } from "node:path";
13
+
14
+ // src/lib/notify.ts
15
+ import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, statSync as statSync2, unlinkSync as unlinkSync2, writeSync } from "node:fs";
16
+ import { homedir as homedir2 } from "node:os";
17
+ import { join as join3 } from "node:path";
18
+
19
+ // src/lib/pool.ts
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
11
21
  import { homedir } from "node:os";
12
22
  import { join as join2 } from "node:path";
13
23
 
@@ -31,6 +41,36 @@ var findPackageRoot = (start) => {
31
41
  };
32
42
  var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
33
43
 
44
+ // src/lib/pool.ts
45
+ var CLAIM_TTL_MS = 10 * 60 * 1e3;
46
+ var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
47
+ var ORPHAN_TMP_MS = 60 * 1e3;
48
+ var CATALOG_TTL_MS = 10 * 60 * 1e3;
49
+ var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
50
+ var STUCK_IDLE_MS = 3 * 60 * 1e3;
51
+ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
52
+ var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
53
+ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
54
+ var COOLDOWN_BASE_MS = 60 * 1e3;
55
+ var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
56
+ var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
57
+ var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
58
+ var STRIKE_DECAY_MS = 30 * 60 * 1e3;
59
+ var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
60
+ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
61
+ var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
62
+ var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
63
+ var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
64
+ var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
65
+ var CHILD_MEMORY_MS = 30 * 6e4;
66
+
67
+ // src/lib/notify.ts
68
+ var NOTIFY_SESSION_ENV = "LACODE_NOTIFY_SESSION";
69
+ var NOTIFY_CLOSE_ENV = "LACODE_NOTIFY_CLOSE";
70
+ var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join3(homedir2(), ".local", "share", "opencode", "lacode", "notify");
71
+ var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
72
+ var notificationIdFile = (correlationKey) => join3(notificationStateDir(), `${safeCorrelationKey(correlationKey)}.ids`);
73
+
34
74
  // src/tunnel/index.ts
35
75
  var ICONS = [
36
76
  "utilities-terminal",
@@ -53,7 +93,7 @@ function resolveTunnel(env = process.env) {
53
93
  user: env.OC_LOCAL_USER ?? "",
54
94
  host: env.OC_LOCAL_HOST ?? "",
55
95
  port: Number.isFinite(port) && port > 0 ? port : 10022,
56
- key: env.OC_NOTIFY_KEY ?? join2(homedir(), ".ssh", "lacode_notify"),
96
+ key: env.OC_NOTIFY_KEY ?? join4(homedir3(), ".ssh", "lacode_notify"),
57
97
  connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
58
98
  };
59
99
  }
@@ -76,15 +116,37 @@ var safeText = (raw, fallback, max) => {
76
116
  };
77
117
  var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
78
118
  var safeMessage = (raw) => safeText(raw ?? "", "", 400);
119
+ var safeRequestId = (raw) => {
120
+ const clean = (raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
121
+ if (!clean) return void 0;
122
+ return clean.length > 128 ? clean.slice(0, 128) : clean;
123
+ };
124
+ var CLOSE_REQUEST_PREFIX = "close:";
125
+ var wireCorrelationKey = (raw) => safeText(raw ?? "", "", 200);
126
+ var presentCorrelationKey = (raw) => {
127
+ const clean = wireCorrelationKey(raw);
128
+ return clean ? clean : void 0;
129
+ };
130
+ var isCloseRequest = (correlationKey) => typeof correlationKey === "string" && correlationKey.startsWith(CLOSE_REQUEST_PREFIX);
131
+ var closeRequestTarget = (correlationKey) => {
132
+ if (!isCloseRequest(correlationKey)) return void 0;
133
+ const target = correlationKey?.slice(CLOSE_REQUEST_PREFIX.length).trim() ?? "";
134
+ return target ? target : void 0;
135
+ };
79
136
  var FIELD_SEPARATOR = "\n";
80
137
  function parsePayload(raw) {
81
- const [title, message, urgency, icon, sound] = raw.split(FIELD_SEPARATOR);
138
+ const [title, message, urgency, icon, sound, requestID, correlationKey] = raw.split(FIELD_SEPARATOR);
139
+ const key = wireCorrelationKey(correlationKey);
82
140
  return {
83
141
  title: safeTitle(title),
84
142
  message: safeMessage(message),
85
143
  urgency: normalizeUrgency(urgency),
86
144
  icon: normalizeIcon(icon),
87
- sound: normalizeSound(sound)
145
+ sound: normalizeSound(sound),
146
+ requestID: safeRequestId(requestID),
147
+ // The seventh field, empty when there is none. A close request keeps its
148
+ // marker here, so the receiver can still tell it apart from a session.
149
+ ...key ? { correlationKey: key } : {}
88
150
  };
89
151
  }
90
152
  var encodePayload = (request) => [
@@ -92,7 +154,14 @@ var encodePayload = (request) => [
92
154
  safeMessage(request.message),
93
155
  normalizeUrgency(request.urgency),
94
156
  normalizeIcon(request.icon),
95
- normalizeSound(request.sound)
157
+ normalizeSound(request.sound),
158
+ // The sixth field, empty when there is no request id: an absent id and an
159
+ // empty one mean the same thing, and the field count never changes.
160
+ safeRequestId(request.requestID) ?? "",
161
+ // The seventh field, the correlation key, empty when there is none. The
162
+ // `close:` marker of a close request is part of the key and travels with
163
+ // it, so the far end closes instead of raising.
164
+ wireCorrelationKey(request.correlationKey)
96
165
  ].join(FIELD_SEPARATOR);
97
166
  var SSH_OPTS = [
98
167
  "-o",
@@ -112,11 +181,16 @@ var CONTROL_OPTS = [
112
181
  "-o",
113
182
  "ControlPersist=300",
114
183
  "-o",
115
- `ControlPath=${join2(homedir(), ".ssh", "lacode-cm-%C")}`
184
+ `ControlPath=${join4(homedir3(), ".ssh", "lacode-cm-%C")}`
116
185
  ];
117
- var run = (command2, args, timeout) => {
186
+ var run = (command2, args, timeout, extraEnv) => {
118
187
  try {
119
- const result = spawnSync(command2, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
188
+ const result = spawnSync(command2, args, {
189
+ timeout,
190
+ encoding: "utf8",
191
+ stdio: ["ignore", "pipe", "pipe"],
192
+ ...extraEnv ? { env: { ...process.env, ...extraEnv } } : {}
193
+ });
120
194
  return {
121
195
  ok: result.status === 0,
122
196
  stdout: result.stdout ?? "",
@@ -175,14 +249,105 @@ var NO_CHANNEL = {
175
249
  linux: "notify-send is not installed",
176
250
  darwin: "neither osascript nor terminal-notifier is installed"
177
251
  };
252
+ var NEVER_EXPIRE_FLAG = "--expire-time=0";
253
+ var APP_NAME_FLAG = "--app-name=LaCode";
254
+ var NOTIFICATION_ACTIONS = [
255
+ ["once", "Allow once"],
256
+ ["always", "Always allow"],
257
+ ["reject", "Deny"]
258
+ ];
259
+ var ACTION_WAIT_MS = 10 * 6e4;
260
+ var isNotificationId = (id) => /^\d+$/.test(id);
261
+ var recordNotificationId = (correlationKey, id) => {
262
+ if (!isNotificationId(id)) return false;
263
+ try {
264
+ mkdirSync3(notificationStateDir(), { recursive: true });
265
+ appendFileSync(notificationIdFile(correlationKey), `${id}
266
+ `, "utf8");
267
+ return true;
268
+ } catch {
269
+ return false;
270
+ }
271
+ };
272
+ var sessionBusEnv = () => {
273
+ if (process.env.DBUS_SESSION_BUS_ADDRESS) return {};
274
+ const uid = typeof process.getuid === "function" ? process.getuid() : void 0;
275
+ return uid === void 0 ? {} : { DBUS_SESSION_BUS_ADDRESS: `unix:path=/run/user/${uid}/bus` };
276
+ };
277
+ var CLOSE_TIMEOUT_MS = 5e3;
278
+ function closeNotification(id) {
279
+ if (!isNotificationId(id)) return false;
280
+ const env = sessionBusEnv();
281
+ const viaGdbus = run(
282
+ "gdbus",
283
+ [
284
+ "call",
285
+ "--session",
286
+ "--dest",
287
+ "org.freedesktop.Notifications",
288
+ "--object-path",
289
+ "/org/freedesktop/Notifications",
290
+ "--method",
291
+ "org.freedesktop.Notifications.CloseNotification",
292
+ id
293
+ ],
294
+ CLOSE_TIMEOUT_MS,
295
+ env
296
+ );
297
+ if (viaGdbus.ok) return true;
298
+ const viaDbusSend = run(
299
+ "dbus-send",
300
+ [
301
+ "--session",
302
+ "--dest=org.freedesktop.Notifications",
303
+ "--type=method_call",
304
+ "/org/freedesktop/Notifications",
305
+ "org.freedesktop.Notifications.CloseNotification",
306
+ `uint32:${id}`
307
+ ],
308
+ CLOSE_TIMEOUT_MS,
309
+ env
310
+ );
311
+ return viaDbusSend.ok;
312
+ }
313
+ function closeCorrelationKey(correlationKey) {
314
+ const file = notificationIdFile(correlationKey);
315
+ let content = "";
316
+ try {
317
+ content = readFileSync4(file, "utf8");
318
+ } catch {
319
+ return true;
320
+ }
321
+ let closed = true;
322
+ for (const line of content.split("\n")) {
323
+ const id = line.trim();
324
+ if (!isNotificationId(id)) continue;
325
+ if (!closeNotification(id)) closed = false;
326
+ }
327
+ try {
328
+ unlinkSync3(file);
329
+ } catch {
330
+ }
331
+ return closed;
332
+ }
333
+ var splitNotifyOutput = (stdout) => {
334
+ const newline = stdout.indexOf("\n");
335
+ if (newline === -1) return { id: stdout.trim(), action: "" };
336
+ return { id: stdout.slice(0, newline).trim(), action: stdout.slice(newline + 1).trim() };
337
+ };
178
338
  function sendLocal(request, platform = process.platform) {
339
+ const closing = closeRequestTarget(request.correlationKey);
340
+ if (closing !== void 0) {
341
+ const closed = closeCorrelationKey(closing);
342
+ return { ok: closed, detail: closed ? void 0 : "a popup could not be closed", transport: "local" };
343
+ }
179
344
  const sound = normalizeSound(request.sound);
180
345
  const playSound = () => {
181
346
  if (sound === "none") return;
182
347
  const channel2 = soundChannel(platform);
183
348
  if (channel2 === "none") return;
184
- const path = join2(PACKAGE_ROOT, "sounds", `${sound}.wav`);
185
- if (!existsSync(path)) return;
349
+ const path = join4(PACKAGE_ROOT, "sounds", `${sound}.wav`);
350
+ if (!existsSync2(path)) return;
186
351
  const child = spawn(channel2, [path], { detached: true, stdio: "ignore" });
187
352
  child.on("error", () => {
188
353
  });
@@ -195,35 +360,80 @@ function sendLocal(request, platform = process.platform) {
195
360
  }
196
361
  const title = safeTitle(request.title);
197
362
  const body = safeMessage(request.message);
363
+ const requestID = safeRequestId(request.requestID);
364
+ const actionable = channel === "notify-send" && requestID !== void 0;
365
+ const actionArgs = actionable ? NOTIFICATION_ACTIONS.map(([name, label]) => `--action=${name}=${label}`) : [];
366
+ const correlationKey = presentCorrelationKey(request.correlationKey);
367
+ const trackable = channel === "notify-send" && correlationKey !== void 0;
198
368
  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)] : (
199
- // Byte-identical to the notify-send call this package has always made.
200
- [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
369
+ // Never auto-dismissed: the notification waits for the user to close it.
370
+ [title, body, APP_NAME_FLAG, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`, NEVER_EXPIRE_FLAG, ...trackable ? ["--print-id"] : [], ...actionArgs]
201
371
  );
202
- const result = run(CHANNEL_BINARY[channel], args, 5e3);
372
+ const result = run(CHANNEL_BINARY[channel], args, actionable ? ACTION_WAIT_MS : 5e3);
203
373
  playSound();
204
- return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
374
+ const printed = trackable ? splitNotifyOutput(result.stdout) : { id: "", action: result.stdout.trim() };
375
+ const action = actionable ? printed.action : "";
376
+ if (correlationKey !== void 0 && printed.id) recordNotificationId(correlationKey, printed.id);
377
+ return {
378
+ ok: result.ok,
379
+ detail: result.ok ? void 0 : result.stderr || result.detail,
380
+ transport: "local",
381
+ ...action ? { action } : {}
382
+ };
205
383
  }
206
384
  function sendRemote(request, tunnel) {
207
385
  if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
208
386
  const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
387
+ const actionable = safeRequestId(request.requestID) !== void 0;
388
+ const timeoutMs = actionable ? Math.max(tunnel.connectTimeoutMs + 2e3, ACTION_WAIT_MS + 2e3) : tunnel.connectTimeoutMs + 2e3;
389
+ const deliver = (args) => {
390
+ const result = run("ssh", args, timeoutMs);
391
+ const action = actionable ? result.stdout.trim() : "";
392
+ return {
393
+ ok: result.ok,
394
+ detail: result.ok ? void 0 : result.stderr || result.detail,
395
+ transport: "remote",
396
+ ...action ? { action } : {}
397
+ };
398
+ };
209
399
  if (tunnel.host) {
210
- const result2 = run(
211
- "ssh",
212
- [...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
213
- tunnel.connectTimeoutMs + 2e3
214
- );
215
- return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
216
- }
217
- const result = run(
218
- "ssh",
219
- [...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
220
- tunnel.connectTimeoutMs + 2e3
221
- );
222
- return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
400
+ return deliver([
401
+ ...SSH_OPTS,
402
+ ...CONTROL_OPTS,
403
+ ...connect,
404
+ "-i",
405
+ tunnel.key,
406
+ `${tunnel.user}@${tunnel.host}`,
407
+ "--",
408
+ encodePayload(request)
409
+ ]);
410
+ }
411
+ return deliver([
412
+ ...SSH_OPTS,
413
+ ...connect,
414
+ "-i",
415
+ tunnel.key,
416
+ "-p",
417
+ String(tunnel.port),
418
+ `${tunnel.user}@localhost`,
419
+ "--",
420
+ encodePayload(request)
421
+ ]);
223
422
  }
224
423
  function notify(request, env = process.env, platform = process.platform) {
225
424
  try {
226
- return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
425
+ const closeKey = (env[NOTIFY_CLOSE_ENV] ?? "").trim();
426
+ if (closeKey) {
427
+ if (!isRemote(env)) {
428
+ const closed = closeCorrelationKey(closeKey);
429
+ return { ok: closed, detail: closed ? void 0 : "a popup could not be closed", transport: "local" };
430
+ }
431
+ return sendRemote({ ...request, correlationKey: `${CLOSE_REQUEST_PREFIX}${closeKey}` }, resolveTunnel(env));
432
+ }
433
+ const sessionKey = presentCorrelationKey(env[NOTIFY_SESSION_ENV]);
434
+ const tracked = sessionKey ? { ...request, correlationKey: request.correlationKey ?? sessionKey } : request;
435
+ const result = isRemote(env) ? sendRemote(tracked, resolveTunnel(env)) : sendLocal(tracked, platform);
436
+ return result.action ? { ...result, action: result.action } : result;
227
437
  } catch (error) {
228
438
  return { ok: false, detail: String(error), transport: "none" };
229
439
  }
@@ -256,18 +466,18 @@ function describe(env = process.env, platform = process.platform) {
256
466
 
257
467
  // src/lib/selfupdate.ts
258
468
  import { spawn as spawn2 } from "node:child_process";
259
- import { closeSync, mkdirSync, openSync, readFileSync as readFileSync2, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
260
- import { homedir as homedir2 } from "node:os";
261
- import { dirname as dirname2, join as join3, resolve } from "node:path";
469
+ import { closeSync as closeSync2, mkdirSync as mkdirSync4, openSync as openSync2, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync4, writeFileSync as writeFileSync2, writeSync as writeSync2 } from "node:fs";
470
+ import { homedir as homedir4 } from "node:os";
471
+ import { dirname as dirname2, join as join5, resolve } from "node:path";
262
472
  var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
263
473
  var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
264
474
  var FETCH_TIMEOUT_MS = 4e3;
265
475
  var INSTALL_TIMEOUT_MS = 12e4;
266
476
  var LOCK_STALE_MS = 10 * 60 * 1e3;
267
477
  var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
268
- var STATE_DIR = join3(homedir2(), ".local", "share", "opencode", "lacode");
269
- var STATE_PATH = join3(STATE_DIR, "selfupdate.json");
270
- var LOCK_PATH = join3(STATE_DIR, "selfupdate.lock");
478
+ var STATE_DIR = join5(homedir4(), ".local", "share", "opencode", "lacode");
479
+ var STATE_PATH = join5(STATE_DIR, "selfupdate.json");
480
+ var LOCK_PATH = join5(STATE_DIR, "selfupdate.lock");
271
481
  function compareSemver(a, b) {
272
482
  const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
273
483
  const left = parse(a);
@@ -281,13 +491,13 @@ function compareSemver(a, b) {
281
491
  }
282
492
  function readInstalledVersion(packageRoot, read = (path) => {
283
493
  try {
284
- return readFileSync2(path, "utf8");
494
+ return readFileSync5(path, "utf8");
285
495
  } catch {
286
496
  return void 0;
287
497
  }
288
498
  }) {
289
499
  try {
290
- const raw = read(join3(packageRoot, "package.json"));
500
+ const raw = read(join5(packageRoot, "package.json"));
291
501
  if (raw === void 0) return void 0;
292
502
  const manifest = JSON.parse(raw);
293
503
  if (manifest.name !== PACKAGE_NAME) return void 0;
@@ -298,20 +508,20 @@ function readInstalledVersion(packageRoot, read = (path) => {
298
508
  }
299
509
  function resolveCacheRoot(packageRoot, exists = (p) => {
300
510
  try {
301
- return statSync(p).isFile();
511
+ return statSync3(p).isFile();
302
512
  } catch {
303
513
  return false;
304
514
  }
305
515
  }) {
306
- const tail = join3("node_modules", "@lawaty", "lacode");
516
+ const tail = join5("node_modules", "@lawaty", "lacode");
307
517
  if (!packageRoot.endsWith(tail)) return void 0;
308
518
  const cacheRoot = resolve(packageRoot, "..", "..", "..");
309
- if (!exists(join3(cacheRoot, "package.json"))) return void 0;
519
+ if (!exists(join5(cacheRoot, "package.json"))) return void 0;
310
520
  return cacheRoot;
311
521
  }
312
522
  function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
313
523
  try {
314
- return statSync(p).isFile();
524
+ return statSync3(p).isFile();
315
525
  } catch {
316
526
  return false;
317
527
  }
@@ -319,10 +529,10 @@ function resolveOpencodeBin(execPath = process.execPath, env = process.env, exis
319
529
  if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
320
530
  for (const dir of (env.PATH ?? "").split(":")) {
321
531
  if (!dir) continue;
322
- const candidate = join3(dir, "opencode");
532
+ const candidate = join5(dir, "opencode");
323
533
  if (exists(candidate)) return candidate;
324
534
  }
325
- const fallback = join3(homedir2(), ".opencode", "bin", "opencode");
535
+ const fallback = join5(homedir4(), ".opencode", "bin", "opencode");
326
536
  return exists(fallback) ? fallback : void 0;
327
537
  }
328
538
  async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
@@ -354,11 +564,11 @@ async function refreshCache(opts) {
354
564
  const { cacheRoot, bin, latest } = opts;
355
565
  const spawnImpl = opts.spawnImpl ?? spawn2;
356
566
  const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
357
- const fresh = join3(cacheRoot, "node_modules", "@lawaty", "lacode");
567
+ const fresh = join5(cacheRoot, "node_modules", "@lawaty", "lacode");
358
568
  const aside = `${cacheRoot}.stale-${Date.now()}`;
359
569
  try {
360
570
  rmSync(aside, { recursive: true, force: true });
361
- renameSync(cacheRoot, aside);
571
+ renameSync2(cacheRoot, aside);
362
572
  } catch (error) {
363
573
  return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
364
574
  }
@@ -393,7 +603,7 @@ async function refreshCache(opts) {
393
603
  }
394
604
  } else {
395
605
  try {
396
- renameSync(aside, cacheRoot);
606
+ renameSync2(aside, cacheRoot);
397
607
  } catch {
398
608
  }
399
609
  }
@@ -405,9 +615,9 @@ async function refreshCache(opts) {
405
615
 
406
616
  // src/lib/version.ts
407
617
  import { spawnSync as spawnSync2 } from "node:child_process";
408
- import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync, statSync as statSync2 } from "node:fs";
409
- import { homedir as homedir3 } from "node:os";
410
- import { isAbsolute, join as join4, relative, sep } from "node:path";
618
+ import { existsSync as existsSync3, readFileSync as readFileSync6, readdirSync as readdirSync2, statSync as statSync4 } from "node:fs";
619
+ import { homedir as homedir5 } from "node:os";
620
+ import { isAbsolute, join as join6, relative, sep } from "node:path";
411
621
  import { fileURLToPath as fileURLToPath2 } from "node:url";
412
622
 
413
623
  // src/lib/jsonc.ts
@@ -460,15 +670,15 @@ var parseJsonc = (raw) => {
460
670
  // src/lib/version.ts
461
671
  var nodeStat = (path) => {
462
672
  try {
463
- return statSync2(path).mtimeMs;
673
+ return statSync4(path).mtimeMs;
464
674
  } catch {
465
675
  return void 0;
466
676
  }
467
677
  };
468
- var nodeExists = (path) => existsSync2(path);
678
+ var nodeExists = (path) => existsSync3(path);
469
679
  var nodeRead = (path) => {
470
680
  try {
471
- return readFileSync3(path, "utf8");
681
+ return readFileSync6(path, "utf8");
472
682
  } catch {
473
683
  return void 0;
474
684
  }
@@ -484,17 +694,17 @@ var realGit = (root) => (args) => {
484
694
  };
485
695
  var walkSources = (root) => {
486
696
  const found = [];
487
- const stack = [join4(root, "src")];
697
+ const stack = [join6(root, "src")];
488
698
  while (stack.length > 0) {
489
699
  const dir = stack.pop();
490
700
  let names;
491
701
  try {
492
- names = readdirSync(dir);
702
+ names = readdirSync2(dir);
493
703
  } catch {
494
704
  continue;
495
705
  }
496
706
  for (const name of names) {
497
- const full = join4(dir, name);
707
+ const full = join6(dir, name);
498
708
  if (name.endsWith(".ts")) found.push(full);
499
709
  else if (name === "plugins" || name === "lib" || name === "tunnel") stack.push(full);
500
710
  }
@@ -502,8 +712,8 @@ var walkSources = (root) => {
502
712
  return found;
503
713
  };
504
714
  var checkDist = (root, deps) => {
505
- if (!deps.exists(join4(root, "dist"))) return { dist: "unknown" };
506
- const built = deps.stat(join4(root, "dist", "index.js"));
715
+ if (!deps.exists(join6(root, "dist"))) return { dist: "unknown" };
716
+ const built = deps.stat(join6(root, "dist", "index.js"));
507
717
  if (built === void 0) return { dist: "unknown" };
508
718
  const sources = deps.listSources(root).map((file) => ({ file, mtime: deps.stat(file) }));
509
719
  const newer = sources.filter((s) => s.mtime !== void 0 && s.mtime > built).map((s) => relative(root, s.file));
@@ -544,13 +754,13 @@ var resolvesHere = (specifier, packageRoot) => {
544
754
  };
545
755
  function readVersionReport(deps = {}) {
546
756
  const packageRoot = deps.packageRoot ?? PACKAGE_ROOT;
547
- const home = deps.home ?? homedir3();
757
+ const home = deps.home ?? homedir5();
548
758
  const git = deps.git ?? realGit(packageRoot);
549
759
  const readFile = deps.readFile ?? nodeRead;
550
760
  const stat = deps.stat ?? nodeStat;
551
761
  const exists = deps.exists ?? nodeExists;
552
762
  const listSources = deps.listSources ?? walkSources;
553
- const isCheckout = exists(join4(packageRoot, ".git"));
763
+ const isCheckout = exists(join6(packageRoot, ".git"));
554
764
  const report = {
555
765
  version: readInstalledVersion(packageRoot, readFile),
556
766
  root: packageRoot,
@@ -563,11 +773,11 @@ function readVersionReport(deps = {}) {
563
773
  const dist = checkDist(packageRoot, { stat, exists, listSources });
564
774
  report.dist = dist.dist;
565
775
  if (dist.stale) report.staleSources = dist.stale;
566
- const configPath = [join4(home, ".config", "opencode", "opencode.jsonc"), join4(home, ".config", "opencode", "opencode.json")].find(
776
+ const configPath = [join6(home, ".config", "opencode", "opencode.jsonc"), join6(home, ".config", "opencode", "opencode.json")].find(
567
777
  (candidate) => exists(candidate)
568
778
  );
569
779
  if (!configPath) {
570
- report.note = `No opencode config found under ${join4(home, ".config", "opencode")}; nothing to compare this copy against.`;
780
+ report.note = `No opencode config found under ${join6(home, ".config", "opencode")}; nothing to compare this copy against.`;
571
781
  return report;
572
782
  }
573
783
  report.configPath = configPath;
@@ -639,6 +849,8 @@ var USAGE = `lacode \u2014 portable notifications and remote exec for LaCode
639
849
  permission | error; an omitted or unrecognised SOUND is none, so a
640
850
  four-argument caller simply gets no sound. Fire-and-forget: always
641
851
  exits 0.
852
+ With LACODE_REQUEST_ID set, the notification carries buttons and the
853
+ name of the clicked one is printed on stdout.
642
854
 
643
855
  lacode forward HOST -- COMMAND [ARG...]
644
856
  Run COMMAND on HOST over ssh. Exits with the remote's status.
@@ -666,7 +878,9 @@ Environment:
666
878
  OC_LOCAL_USER desktop user the tunnel authenticates as
667
879
  OC_TUNNEL_PORT reverse-forward listener port (default 10022)
668
880
  OC_NOTIFY_KEY dedicated key (default ~/.ssh/lacode_notify)
669
- OC_CONNECT_TIMEOUT_MS / OC_SSH_TIMEOUT_MS`;
881
+ OC_CONNECT_TIMEOUT_MS / OC_SSH_TIMEOUT_MS
882
+ LACODE_REQUEST_ID makes a notification actionable: it carries buttons, and
883
+ the clicked one is printed on stdout`;
670
884
  var argv = process.argv.slice(2);
671
885
  var command = argv.shift();
672
886
  switch (command) {
@@ -684,8 +898,16 @@ switch (command) {
684
898
  console.error(USAGE);
685
899
  process.exit(2);
686
900
  }
687
- const result = notify({ title: rawTitle, message, urgency, icon, sound: normalizeSound(sound) });
901
+ const result = notify({
902
+ title: rawTitle,
903
+ message,
904
+ urgency,
905
+ icon,
906
+ sound: normalizeSound(sound),
907
+ requestID: process.env.LACODE_REQUEST_ID
908
+ });
688
909
  if (!result.ok) console.error(`lacode: notification not delivered via ${result.transport}: ${result.detail ?? "unknown reason"}`);
910
+ if (result.action) console.log(result.action);
689
911
  process.exit(0);
690
912
  break;
691
913
  }
@@ -765,6 +987,7 @@ ${report.note}`);
765
987
  const request = parsePayload(process.env.SSH_ORIGINAL_COMMAND ?? "");
766
988
  const result = sendLocal(request);
767
989
  if (!result.ok) console.error(`lacode receive: ${result.detail ?? "not delivered"}`);
990
+ if (result.action) console.log(result.action);
768
991
  process.exit(result.ok ? 0 : 1);
769
992
  break;
770
993
  }
@@ -776,7 +999,7 @@ ${report.note}`);
776
999
  }
777
1000
  const { user, host, key } = resolveTunnel();
778
1001
  const pub = `${key}.pub`;
779
- if (!existsSync3(key)) {
1002
+ if (!existsSync4(key)) {
780
1003
  const gen = spawnSync3("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", "lacode-notify", "-f", key], { stdio: ["ignore", "ignore", "inherit"] });
781
1004
  if (gen.status !== 0) {
782
1005
  console.error("lacode tunnel setup: ssh-keygen failed; create the key by hand and re-run.");
@@ -784,7 +1007,7 @@ ${report.note}`);
784
1007
  }
785
1008
  console.log(`Generated ${key}`);
786
1009
  }
787
- const pubkey = existsSync3(pub) ? readFileSync4(pub, "utf8").trim() : "(public key missing)";
1010
+ const pubkey = existsSync4(pub) ? readFileSync7(pub, "utf8").trim() : "(public key missing)";
788
1011
  const self = process.argv[1] ?? "lacode";
789
1012
  console.log("\nOn the REMOTE host (where opencode runs), export:");
790
1013
  console.log(` OC_LOCAL_USER=${user || "<desktop-user>"}`);