@lawaty/lacode 0.2.2 → 0.4.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,13 +2,36 @@
2
2
 
3
3
  // bin/lacode
4
4
  import { fileURLToPath as fileURLToPath2 } from "node:url";
5
- import { existsSync, readFileSync as readFileSync3 } from "node:fs";
5
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
6
6
  import { spawnSync as spawnSync2 } from "node:child_process";
7
7
 
8
8
  // src/tunnel/index.ts
9
- import { spawnSync } from "node:child_process";
9
+ import { spawn, spawnSync } from "node:child_process";
10
+ import { existsSync } from "node:fs";
10
11
  import { homedir } from "node:os";
11
- import { join } from "node:path";
12
+ import { join as join2 } from "node:path";
13
+
14
+ // src/lib/package-root.ts
15
+ import { readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ var PACKAGE_NAME = "@lawaty/lacode";
19
+ var findPackageRoot = (start) => {
20
+ let dir = start;
21
+ for (let up = 0; up < 6; up++) {
22
+ try {
23
+ if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === PACKAGE_NAME) return dir;
24
+ } catch {
25
+ }
26
+ const parent = dirname(dir);
27
+ if (parent === dir) break;
28
+ dir = parent;
29
+ }
30
+ return join(start, "..", "..");
31
+ };
32
+ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
33
+
34
+ // src/tunnel/index.ts
12
35
  var ICONS = [
13
36
  "utilities-terminal",
14
37
  "dialog-information",
@@ -17,13 +40,20 @@ var ICONS = [
17
40
  "system-run",
18
41
  "emblem-default"
19
42
  ];
43
+ var SOUNDS = [
44
+ "none",
45
+ "done",
46
+ "question",
47
+ "permission",
48
+ "error"
49
+ ];
20
50
  function resolveTunnel(env = process.env) {
21
51
  const port = Number(env.OC_TUNNEL_PORT);
22
52
  return {
23
53
  user: env.OC_LOCAL_USER ?? "",
24
54
  host: env.OC_LOCAL_HOST ?? "",
25
55
  port: Number.isFinite(port) && port > 0 ? port : 10022,
26
- key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
56
+ key: env.OC_NOTIFY_KEY ?? join2(homedir(), ".ssh", "lacode_notify"),
27
57
  connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
28
58
  };
29
59
  }
@@ -36,6 +66,9 @@ function normalizeUrgency(raw) {
36
66
  function normalizeIcon(raw) {
37
67
  return ICONS.includes(raw) ? raw : "utilities-terminal";
38
68
  }
69
+ function normalizeSound(raw) {
70
+ return SOUNDS.includes(raw) ? raw : "none";
71
+ }
39
72
  var safeText = (raw, fallback, max) => {
40
73
  const clean = raw.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
41
74
  if (!clean) return fallback;
@@ -45,19 +78,21 @@ var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
45
78
  var safeMessage = (raw) => safeText(raw ?? "", "", 400);
46
79
  var FIELD_SEPARATOR = "\n";
47
80
  function parsePayload(raw) {
48
- const [title, message, urgency, icon] = raw.split(FIELD_SEPARATOR);
81
+ const [title, message, urgency, icon, sound] = raw.split(FIELD_SEPARATOR);
49
82
  return {
50
83
  title: safeTitle(title),
51
84
  message: safeMessage(message),
52
85
  urgency: normalizeUrgency(urgency),
53
- icon: normalizeIcon(icon)
86
+ icon: normalizeIcon(icon),
87
+ sound: normalizeSound(sound)
54
88
  };
55
89
  }
56
90
  var encodePayload = (request) => [
57
91
  safeTitle(request.title),
58
92
  safeMessage(request.message),
59
93
  normalizeUrgency(request.urgency),
60
- normalizeIcon(request.icon)
94
+ normalizeIcon(request.icon),
95
+ normalizeSound(request.sound)
61
96
  ].join(FIELD_SEPARATOR);
62
97
  var SSH_OPTS = [
63
98
  "-o",
@@ -77,7 +112,7 @@ var CONTROL_OPTS = [
77
112
  "-o",
78
113
  "ControlPersist=300",
79
114
  "-o",
80
- `ControlPath=${join(homedir(), ".ssh", "lacode-cm-%C")}`
115
+ `ControlPath=${join2(homedir(), ".ssh", "lacode-cm-%C")}`
81
116
  ];
82
117
  var run = (command2, args, timeout) => {
83
118
  try {
@@ -106,6 +141,22 @@ var CHANNEL_BINARY = {
106
141
  osascript: "osascript",
107
142
  "terminal-notifier": "terminal-notifier"
108
143
  };
144
+ var SOUND_BINARY = {
145
+ afplay: "afplay",
146
+ paplay: "paplay",
147
+ aplay: "aplay",
148
+ "pw-play": "pw-play"
149
+ };
150
+ function soundChannel(platform = process.platform) {
151
+ if (platform === "darwin") {
152
+ if (have(SOUND_BINARY.afplay)) return "afplay";
153
+ } else {
154
+ for (const cmd of [SOUND_BINARY.paplay, SOUND_BINARY.aplay, SOUND_BINARY["pw-play"]]) {
155
+ if (have(cmd)) return cmd;
156
+ }
157
+ }
158
+ return "none";
159
+ }
109
160
  var DARWIN_SCRIPT = [
110
161
  "on run argv",
111
162
  " display notification (item 3 of argv) with title (item 2 of argv)",
@@ -125,8 +176,21 @@ var NO_CHANNEL = {
125
176
  darwin: "neither osascript nor terminal-notifier is installed"
126
177
  };
127
178
  function sendLocal(request, platform = process.platform) {
179
+ const sound = normalizeSound(request.sound);
180
+ const playSound = () => {
181
+ if (sound === "none") return;
182
+ const channel2 = soundChannel(platform);
183
+ if (channel2 === "none") return;
184
+ const path = join2(PACKAGE_ROOT, "sounds", `${sound}.wav`);
185
+ if (!existsSync(path)) return;
186
+ const child = spawn(channel2, [path], { detached: true, stdio: "ignore" });
187
+ child.on("error", () => {
188
+ });
189
+ child.unref();
190
+ };
128
191
  const channel = localChannel(platform);
129
192
  if (channel === "none") {
193
+ playSound();
130
194
  return { ok: false, detail: NO_CHANNEL[platform] ?? `no local notification channel on ${platform}`, transport: "local" };
131
195
  }
132
196
  const title = safeTitle(request.title);
@@ -136,6 +200,7 @@ function sendLocal(request, platform = process.platform) {
136
200
  [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
137
201
  );
138
202
  const result = run(CHANNEL_BINARY[channel], args, 5e3);
203
+ playSound();
139
204
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
140
205
  }
141
206
  function sendRemote(request, tunnel) {
@@ -189,28 +254,8 @@ function describe(env = process.env, platform = process.platform) {
189
254
  };
190
255
  }
191
256
 
192
- // src/lib/package-root.ts
193
- import { readFileSync } from "node:fs";
194
- import { dirname, join as join2 } from "node:path";
195
- import { fileURLToPath } from "node:url";
196
- var PACKAGE_NAME = "@lawaty/lacode";
197
- var findPackageRoot = (start) => {
198
- let dir = start;
199
- for (let up = 0; up < 6; up++) {
200
- try {
201
- if (JSON.parse(readFileSync(join2(dir, "package.json"), "utf8")).name === PACKAGE_NAME) return dir;
202
- } catch {
203
- }
204
- const parent = dirname(dir);
205
- if (parent === dir) break;
206
- dir = parent;
207
- }
208
- return join2(start, "..", "..");
209
- };
210
- var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
211
-
212
257
  // src/lib/selfupdate.ts
213
- import { spawn } from "node:child_process";
258
+ import { spawn as spawn2 } from "node:child_process";
214
259
  import { closeSync, mkdirSync, openSync, readFileSync as readFileSync2, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
215
260
  import { homedir as homedir2 } from "node:os";
216
261
  import { dirname as dirname2, join as join3, resolve } from "node:path";
@@ -299,7 +344,7 @@ async function checkForUpdate(opts) {
299
344
  }
300
345
  async function refreshCache(opts) {
301
346
  const { cacheRoot, bin, latest } = opts;
302
- const spawnImpl = opts.spawnImpl ?? spawn;
347
+ const spawnImpl = opts.spawnImpl ?? spawn2;
303
348
  const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
304
349
  const fresh = join3(cacheRoot, "node_modules", "@lawaty", "lacode");
305
350
  const aside = `${cacheRoot}.stale-${Date.now()}`;
@@ -366,10 +411,13 @@ if (!process.features?.typescript) {
366
411
  }
367
412
  var USAGE = `lacode \u2014 portable notifications and remote exec for LaCode
368
413
 
369
- lacode notify TITLE [MESSAGE] [URGENCY] [ICON]
414
+ lacode notify TITLE [MESSAGE] [URGENCY] [ICON] [SOUND]
370
415
  Send one desktop notification. Local host: straight to the desktop
371
416
  session. Over ssh: handed to the desktop through the reverse forward.
372
- URGENCY is low | normal | critical. Fire-and-forget: always exits 0.
417
+ URGENCY is low | normal | critical. SOUND is none | done | question |
418
+ permission | error; an omitted or unrecognised SOUND is none, so a
419
+ four-argument caller simply gets no sound. Fire-and-forget: always
420
+ exits 0.
373
421
 
374
422
  lacode forward HOST -- COMMAND [ARG...]
375
423
  Run COMMAND on HOST over ssh. Exits with the remote's status.
@@ -400,13 +448,13 @@ switch (command) {
400
448
  process.exit(0);
401
449
  break;
402
450
  case "notify": {
403
- const [rawTitle, message, urgency, icon] = argv;
451
+ const [rawTitle, message, urgency, icon, sound] = argv;
404
452
  if (!rawTitle) {
405
453
  console.error("lacode notify: TITLE is required");
406
454
  console.error(USAGE);
407
455
  process.exit(2);
408
456
  }
409
- const result = notify({ title: rawTitle, message, urgency, icon });
457
+ const result = notify({ title: rawTitle, message, urgency, icon, sound: normalizeSound(sound) });
410
458
  if (!result.ok) console.error(`lacode: notification not delivered via ${result.transport}: ${result.detail ?? "unknown reason"}`);
411
459
  process.exit(0);
412
460
  break;
@@ -486,7 +534,7 @@ switch (command) {
486
534
  }
487
535
  const { user, host, key } = resolveTunnel();
488
536
  const pub = `${key}.pub`;
489
- if (!existsSync(key)) {
537
+ if (!existsSync2(key)) {
490
538
  const gen = spawnSync2("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", "lacode-notify", "-f", key], { stdio: ["ignore", "ignore", "inherit"] });
491
539
  if (gen.status !== 0) {
492
540
  console.error("lacode tunnel setup: ssh-keygen failed; create the key by hand and re-run.");
@@ -494,7 +542,7 @@ switch (command) {
494
542
  }
495
543
  console.log(`Generated ${key}`);
496
544
  }
497
- const pubkey = existsSync(pub) ? readFileSync3(pub, "utf8").trim() : "(public key missing)";
545
+ const pubkey = existsSync2(pub) ? readFileSync3(pub, "utf8").trim() : "(public key missing)";
498
546
  const self = process.argv[1] ?? "lacode";
499
547
  console.log("\nOn the REMOTE host (where opencode runs), export:");
500
548
  console.log(` OC_LOCAL_USER=${user || "<desktop-user>"}`);
package/dist/pool.js CHANGED
@@ -243,6 +243,7 @@ function state(opts = {}) {
243
243
  cooling: /* @__PURE__ */ new Map(),
244
244
  strikes: /* @__PURE__ */ new Map(),
245
245
  activity: /* @__PURE__ */ new Map(),
246
+ children: /* @__PURE__ */ new Map(),
246
247
  reaped: 0,
247
248
  reaping: false,
248
249
  lastSweep: 0,
@@ -284,7 +285,7 @@ function init(s, onWarn) {
284
285
  }
285
286
  function pruneOwn(s) {
286
287
  const now = Date.now();
287
- for (const [key, claim] of s.own) if (now - claim.t > CLAIM_TTL_MS) s.own.delete(key);
288
+ for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
288
289
  }
289
290
  function publish(s) {
290
291
  if (!s.dirReady) return;
@@ -349,7 +350,7 @@ function readSiblings(s) {
349
350
  try {
350
351
  const parsed = JSON.parse(readFileSync2(path, "utf8"));
351
352
  const claims = Object.values(parsed).filter(
352
- (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - c.t <= CLAIM_TTL_MS
353
+ (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - (c.hb ?? c.t) <= CLAIM_TTL_MS
353
354
  );
354
355
  siblings.push({ id: match[1], claims });
355
356
  } catch {
@@ -463,26 +464,72 @@ function snapshot(s) {
463
464
  }
464
465
  var loadOf = (counts, model) => counts.get(model) ?? 0;
465
466
  var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot.weight;
466
- function pickSlot(s, snap) {
467
+ function roundRobinSequence(slots) {
468
+ const seq = [];
469
+ for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
470
+ return seq;
471
+ }
472
+ var CURSOR_FILE = "cursor.json";
473
+ function readCursor(s) {
474
+ try {
475
+ const parsed = JSON.parse(readFileSync2(join2(s.dir, CURSOR_FILE), "utf8"));
476
+ if (typeof parsed.seq === "number" && Number.isInteger(parsed.seq) && parsed.seq >= 0) return parsed.seq;
477
+ } catch {
478
+ }
479
+ return 0;
480
+ }
481
+ function writeCursor(s, seq) {
482
+ if (!s.dirReady) return;
483
+ const path = join2(s.dir, CURSOR_FILE);
484
+ try {
485
+ writeFileSync(`${path}.tmp`, JSON.stringify({ seq }));
486
+ renameSync(`${path}.tmp`, path);
487
+ } catch {
488
+ }
489
+ }
490
+ function pickSlot(s, snap, options) {
491
+ const advance = options?.advance !== false;
467
492
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
493
+ const seq = roundRobinSequence(slots);
494
+ if (seq.length > 0) {
495
+ const byIndex = new Map(slots.map((slot) => [slot.index, slot]));
496
+ const start = readCursor(s) % seq.length;
497
+ for (let step = 0; step < seq.length; step++) {
498
+ const at = (start + step) % seq.length;
499
+ const slot = byIndex.get(seq[at]);
500
+ if (slot && isAvailable(s, slot.model, snap.cooling) && underCeiling(slot, snap.counts)) {
501
+ if (advance) writeCursor(s, at + 1);
502
+ return slot;
503
+ }
504
+ }
505
+ }
468
506
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
469
507
  const pool = usable.length > 0 ? usable : slots;
470
- const open = pool.filter((slot) => underCeiling(slot, snap.counts));
471
- if (open.length > 0) return open.reduce((best, slot) => slot.weight > best.weight ? slot : best, open[0]);
472
508
  return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
473
509
  }
474
510
  function agentFor(base, slot) {
475
511
  return `${base}-${slot}`;
476
512
  }
477
513
  function acquire(s, base, slot, ownerSession, key, target) {
478
- s.own.set(key, { v: base, k: slot.index, m: slot.model, t: Date.now(), s: ownerSession, g: target });
479
- if (target) s.activity.set(target, Date.now());
514
+ const now = Date.now();
515
+ s.own.set(key, { v: base, k: slot.index, m: slot.model, t: now, hb: now, s: ownerSession, g: target });
516
+ if (target) s.activity.set(target, now);
480
517
  publish(s);
481
518
  }
482
519
  function release(s, key) {
483
520
  if (s.own.delete(key)) publish(s);
484
521
  }
522
+ var CHILD_RING = 8;
523
+ var CHILD_MEMORY_MS = 30 * 6e4;
524
+ var CHILD_SLACK_MS = 1e3;
525
+ function noteChild(s, parentID, childID) {
526
+ const ring = s.children.get(parentID) ?? [];
527
+ ring.push({ id: childID, t: Date.now() });
528
+ while (ring.length > CHILD_RING) ring.shift();
529
+ s.children.set(parentID, ring);
530
+ }
485
531
  function bindChild(s, parentID, childID) {
532
+ noteChild(s, parentID, childID);
486
533
  const candidates = [...s.own.entries()].filter(([, c]) => c.s === parentID && !c.g).sort((a, b) => a[1].t - b[1].t);
487
534
  if (candidates.length === 0) return false;
488
535
  candidates[0][1].g = childID;
@@ -490,8 +537,29 @@ function bindChild(s, parentID, childID) {
490
537
  publish(s);
491
538
  return true;
492
539
  }
540
+ function backfillChild(s, claim) {
541
+ if (claim.g || !claim.s) return false;
542
+ const now = Date.now();
543
+ const taken = new Set([...s.own.values()].map((c) => c.g).filter((g) => typeof g === "string"));
544
+ const ring = s.children.get(claim.s) ?? [];
545
+ for (let i = ring.length - 1; i >= 0; i--) {
546
+ const child = ring[i];
547
+ if (now - child.t > CHILD_MEMORY_MS) break;
548
+ if (child.t < claim.t - CHILD_SLACK_MS) continue;
549
+ if (taken.has(child.id)) continue;
550
+ claim.g = child.id;
551
+ s.activity.set(child.id, now);
552
+ publish(s);
553
+ return true;
554
+ }
555
+ return false;
556
+ }
493
557
  function noteActivity(s, sessionID) {
494
- s.activity.set(sessionID, Date.now());
558
+ const now = Date.now();
559
+ s.activity.set(sessionID, now);
560
+ let touched = false;
561
+ for (const claim of s.own.values()) if (claim.g === sessionID) claim.hb = now, touched = true;
562
+ if (touched) publish(s);
495
563
  }
496
564
  function idleFor(s, claim, now) {
497
565
  const target = claim.g;
@@ -555,6 +623,7 @@ export {
555
623
  USER_MODELS_TXT,
556
624
  acquire,
557
625
  agentFor,
626
+ backfillChild,
558
627
  bindChild,
559
628
  claimKey,
560
629
  coolProvider,
@@ -585,6 +654,7 @@ export {
585
654
  releaseStuck,
586
655
  resolveModels,
587
656
  retryAfterMs,
657
+ roundRobinSequence,
588
658
  selfFile,
589
659
  slotFor,
590
660
  snapshot,
package/dist/tunnel.js CHANGED
@@ -1,7 +1,30 @@
1
1
  // src/tunnel/index.ts
2
- import { spawnSync } from "node:child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
3
4
  import { homedir } from "node:os";
4
- import { join } from "node:path";
5
+ import { join as join2 } from "node:path";
6
+
7
+ // src/lib/package-root.ts
8
+ import { readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ var PACKAGE_NAME = "@lawaty/lacode";
12
+ var findPackageRoot = (start) => {
13
+ let dir = start;
14
+ for (let up = 0; up < 6; up++) {
15
+ try {
16
+ if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === PACKAGE_NAME) return dir;
17
+ } catch {
18
+ }
19
+ const parent = dirname(dir);
20
+ if (parent === dir) break;
21
+ dir = parent;
22
+ }
23
+ return join(start, "..", "..");
24
+ };
25
+ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
26
+
27
+ // src/tunnel/index.ts
5
28
  var ICONS = [
6
29
  "utilities-terminal",
7
30
  "dialog-information",
@@ -10,13 +33,20 @@ var ICONS = [
10
33
  "system-run",
11
34
  "emblem-default"
12
35
  ];
36
+ var SOUNDS = [
37
+ "none",
38
+ "done",
39
+ "question",
40
+ "permission",
41
+ "error"
42
+ ];
13
43
  function resolveTunnel(env = process.env) {
14
44
  const port = Number(env.OC_TUNNEL_PORT);
15
45
  return {
16
46
  user: env.OC_LOCAL_USER ?? "",
17
47
  host: env.OC_LOCAL_HOST ?? "",
18
48
  port: Number.isFinite(port) && port > 0 ? port : 10022,
19
- key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
49
+ key: env.OC_NOTIFY_KEY ?? join2(homedir(), ".ssh", "lacode_notify"),
20
50
  connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
21
51
  };
22
52
  }
@@ -29,6 +59,9 @@ function normalizeUrgency(raw) {
29
59
  function normalizeIcon(raw) {
30
60
  return ICONS.includes(raw) ? raw : "utilities-terminal";
31
61
  }
62
+ function normalizeSound(raw) {
63
+ return SOUNDS.includes(raw) ? raw : "none";
64
+ }
32
65
  var safeText = (raw, fallback, max) => {
33
66
  const clean = raw.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
34
67
  if (!clean) return fallback;
@@ -38,19 +71,21 @@ var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
38
71
  var safeMessage = (raw) => safeText(raw ?? "", "", 400);
39
72
  var FIELD_SEPARATOR = "\n";
40
73
  function parsePayload(raw) {
41
- const [title, message, urgency, icon] = raw.split(FIELD_SEPARATOR);
74
+ const [title, message, urgency, icon, sound] = raw.split(FIELD_SEPARATOR);
42
75
  return {
43
76
  title: safeTitle(title),
44
77
  message: safeMessage(message),
45
78
  urgency: normalizeUrgency(urgency),
46
- icon: normalizeIcon(icon)
79
+ icon: normalizeIcon(icon),
80
+ sound: normalizeSound(sound)
47
81
  };
48
82
  }
49
83
  var encodePayload = (request) => [
50
84
  safeTitle(request.title),
51
85
  safeMessage(request.message),
52
86
  normalizeUrgency(request.urgency),
53
- normalizeIcon(request.icon)
87
+ normalizeIcon(request.icon),
88
+ normalizeSound(request.sound)
54
89
  ].join(FIELD_SEPARATOR);
55
90
  var SSH_OPTS = [
56
91
  "-o",
@@ -70,7 +105,7 @@ var CONTROL_OPTS = [
70
105
  "-o",
71
106
  "ControlPersist=300",
72
107
  "-o",
73
- `ControlPath=${join(homedir(), ".ssh", "lacode-cm-%C")}`
108
+ `ControlPath=${join2(homedir(), ".ssh", "lacode-cm-%C")}`
74
109
  ];
75
110
  var run = (command, args, timeout) => {
76
111
  try {
@@ -99,6 +134,22 @@ var CHANNEL_BINARY = {
99
134
  osascript: "osascript",
100
135
  "terminal-notifier": "terminal-notifier"
101
136
  };
137
+ var SOUND_BINARY = {
138
+ afplay: "afplay",
139
+ paplay: "paplay",
140
+ aplay: "aplay",
141
+ "pw-play": "pw-play"
142
+ };
143
+ function soundChannel(platform = process.platform) {
144
+ if (platform === "darwin") {
145
+ if (have(SOUND_BINARY.afplay)) return "afplay";
146
+ } else {
147
+ for (const cmd of [SOUND_BINARY.paplay, SOUND_BINARY.aplay, SOUND_BINARY["pw-play"]]) {
148
+ if (have(cmd)) return cmd;
149
+ }
150
+ }
151
+ return "none";
152
+ }
102
153
  var DARWIN_SCRIPT = [
103
154
  "on run argv",
104
155
  " display notification (item 3 of argv) with title (item 2 of argv)",
@@ -118,8 +169,21 @@ var NO_CHANNEL = {
118
169
  darwin: "neither osascript nor terminal-notifier is installed"
119
170
  };
120
171
  function sendLocal(request, platform = process.platform) {
172
+ const sound = normalizeSound(request.sound);
173
+ const playSound = () => {
174
+ if (sound === "none") return;
175
+ const channel2 = soundChannel(platform);
176
+ if (channel2 === "none") return;
177
+ const path = join2(PACKAGE_ROOT, "sounds", `${sound}.wav`);
178
+ if (!existsSync(path)) return;
179
+ const child = spawn(channel2, [path], { detached: true, stdio: "ignore" });
180
+ child.on("error", () => {
181
+ });
182
+ child.unref();
183
+ };
121
184
  const channel = localChannel(platform);
122
185
  if (channel === "none") {
186
+ playSound();
123
187
  return { ok: false, detail: NO_CHANNEL[platform] ?? `no local notification channel on ${platform}`, transport: "local" };
124
188
  }
125
189
  const title = safeTitle(request.title);
@@ -129,6 +193,7 @@ function sendLocal(request, platform = process.platform) {
129
193
  [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
130
194
  );
131
195
  const result = run(CHANNEL_BINARY[channel], args, 5e3);
196
+ playSound();
132
197
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
133
198
  }
134
199
  function sendRemote(request, tunnel) {
@@ -184,12 +249,14 @@ function describe(env = process.env, platform = process.platform) {
184
249
  export {
185
250
  FIELD_SEPARATOR,
186
251
  ICONS,
252
+ SOUNDS,
187
253
  describe,
188
254
  encodePayload,
189
255
  forward,
190
256
  isRemote,
191
257
  localChannel,
192
258
  normalizeIcon,
259
+ normalizeSound,
193
260
  normalizeUrgency,
194
261
  notify,
195
262
  parsePayload,
@@ -197,5 +264,6 @@ export {
197
264
  safeMessage,
198
265
  safeTitle,
199
266
  sendLocal,
200
- sendRemote
267
+ sendRemote,
268
+ soundChannel
201
269
  };
package/docs/TUNNEL.md CHANGED
@@ -14,7 +14,7 @@ gets it back.
14
14
  │ │ │ ───────────────────────► │ (authorized_keys │
15
15
  │ ▼ │ ONE argument: │ forced command) │
16
16
  │ lacode notify │ title\nmessage\n │ │ │
17
- │ │ urgency\nicon │ ▼ │
17
+ │ │ urgency\nicon\nsound │ ▼ │
18
18
  │ │ │ notify-send / │
19
19
  │ │ │ osascript │
20
20
  └──────────────────┘ └────────────────────────┘
@@ -27,21 +27,26 @@ listening port, no systemd unit and nothing to sync: the plugin owns the
27
27
  transport, and it exists only while opencode runs — which is the only time a
28
28
  notification matters.
29
29
 
30
- **One argument, not four.** ssh concatenates everything after the host into a
30
+ **One argument, not five.** ssh concatenates everything after the host into a
31
31
  single command string, and a forced command starts with *no* positional
32
- parameters. A sender that passes four quoted arguments arrives as the single
33
- string `"a b c d"`; a receiver reading `$1..$4` sees four empty strings, falls
34
- back to four defaults, and pops up a blank notification. So the payload crosses
32
+ parameters. A sender that passes five quoted arguments arrives as the single
33
+ string `"a b c d e"`; a receiver reading `$1..$5` sees five empty strings, falls
34
+ back to five defaults, and pops up a blank notification. So the payload crosses
35
35
  as one newline-delimited argument:
36
36
 
37
37
  ```
38
- title\nmessage\nurgency\nicon
38
+ title\nmessage\nurgency\nicon\nsound
39
39
  ```
40
40
 
41
+ Five fields, and only the last one is new: `sound` is `none`, `done`,
42
+ `question`, `permission` or `error`. A sender that stops at `icon` simply
43
+ leaves it empty, and an empty or unrecognised name is `none`, so a four-field
44
+ payload is still a valid one.
45
+
41
46
  Newline is safe as the delimiter because both ends strip control characters from
42
- the free-text fields before assembling or reading it, and `urgency` and `icon`
43
- come from whitelists. No field can contain the delimiter, so a message full of
44
- spaces, quotes or `*` survives intact.
47
+ the free-text fields before assembling or reading it, and `urgency`, `icon` and
48
+ `sound` come from whitelists. No field can contain the delimiter, so a message
49
+ full of spaces, quotes or `*` survives intact.
45
50
 
46
51
  ## Setting it up
47
52
 
@@ -81,11 +86,15 @@ with `terminal-notifier` as the macOS fallback when `osascript` is missing.
81
86
 
82
87
  ```bash
83
88
  lacode notify "build finished" "4 files changed" normal dialog-information
84
- lacode notify "question" "which database should I use?" critical dialog-warning
89
+ lacode notify "question" "which database should I use?" critical dialog-warning done
85
90
  lacode forward web-01 -- systemctl --user status something
86
91
  lacode doctor
87
92
  ```
88
93
 
94
+ SOUND is optional and last: `none`, `done`, `question`, `permission` or `error`,
95
+ one bundled tone per outcome. Leave it off and nothing plays; the line above
96
+ without a fifth word is still correct.
97
+
89
98
  `notify` is fire-and-forget by design. An unreachable desktop, a missing key, no
90
99
  notifier installed at all — all of them print a warning on stderr and exit 0. A
91
100
  notification must never be able to fail the process that tried to send one; an
@@ -109,6 +118,14 @@ receives notifications too.
109
118
  It never forwards: `SSH_CLIENT` is set for a forced command as well, so it calls
110
119
  the local channel directly rather than `notify()` (which would loop back).
111
120
 
121
+ It plays the sound too, and that is not decoration. The notification is rendered
122
+ on the desktop, so that is the only place a tone can be heard: the remote host
123
+ runs the session and has no speakers. The name is validated against the sound
124
+ whitelist at the same trust boundary as `urgency` and `icon`, and the receiver
125
+ looks the file up in the package's own `sounds/` directory — the sender's word
126
+ never becomes a path. `afplay` plays it on macOS, `paplay`, `aplay` or
127
+ `pw-play` on Linux, and a machine with none of them still gets the notification.
128
+
112
129
  ## Configuration
113
130
 
114
131
  | Variable | Default | Meaning |
@@ -124,6 +141,16 @@ the local channel directly rather than `notify()` (which would loop back).
124
141
  account frequently returns `root`, and a dbus path built from that reaches
125
142
  nobody's desktop. Guessing the desktop user is worse than failing.
126
143
 
144
+ `LACODE_SOUND` is deliberately absent from that table, and not by oversight:
145
+ every `OC_*` name above is read by the hop — either by `lacode notify` or by the
146
+ ssh call it makes — and `lacode tunnel setup` prints what it exports. Sound is
147
+ not that. It is a plugin-level mute switch read by the process running opencode
148
+ (`sound: false` is the same switch in the config), so listing it here would send
149
+ you to export a variable into a hop environment where nothing looks at it. It is
150
+ documented with the other plugin options in the README. Set it to `0`, `false`,
151
+ `off` or `no` to silence notifications; anything else — a typo included — leaves
152
+ sound on, because a misspelt variable must not be how you turn a feature off.
153
+
127
154
  ## Legacy: the reverse forward
128
155
 
129
156
  The original design opened a reverse forward (`ssh -R`) held by a `systemd --user`