@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/README.md +119 -1
- package/dist/agents.js +1 -0
- package/dist/index.js +156 -34
- package/dist/lacode.js +84 -36
- package/dist/pool.js +78 -8
- package/dist/tunnel.js +76 -8
- package/docs/TUNNEL.md +37 -10
- package/docs/USER-STORIES.md +18 -5
- package/package.json +2 -1
- package/presets/README.md +18 -7
- package/presets/free-tier.json +20 -6
- package/sounds/done.wav +0 -0
- package/sounds/error.wav +0 -0
- package/sounds/permission.wav +0 -0
- package/sounds/question.wav +0 -0
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 ??
|
|
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=${
|
|
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 ??
|
|
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.
|
|
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 (!
|
|
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 =
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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 ??
|
|
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=${
|
|
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
|
|
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
|
|
33
|
-
string `"a b c d"`; a receiver reading `$1..$
|
|
34
|
-
back to
|
|
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
|
|
43
|
-
come from whitelists. No field can contain the delimiter, so a message
|
|
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`
|