@lawaty/lacode 0.0.0-stage → 0.1.1

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/pool.js ADDED
@@ -0,0 +1,487 @@
1
+ // src/lib/pool.ts
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ var CLAIM_TTL_MS = 10 * 60 * 1e3;
7
+ var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
8
+ var ORPHAN_TMP_MS = 60 * 1e3;
9
+ var CATALOG_TTL_MS = 10 * 60 * 1e3;
10
+ var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
11
+ var STUCK_IDLE_MS = 3 * 60 * 1e3;
12
+ var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
13
+ var MAX_WEIGHT = 100;
14
+ var MAX_SLOTS = 4;
15
+ var COOLDOWN_BASE_MS = 60 * 1e3;
16
+ var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
17
+ var STRIKE_DECAY_MS = 30 * 60 * 1e3;
18
+ var BASES = ["explore-fast", "implement-fast", "context-manager"];
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 === "@lawaty/lacode") 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
+ var PRESETS_DIR = join(PACKAGE_ROOT, "presets");
34
+ var DEFAULT_PRESET = join(PRESETS_DIR, "free-tier.json");
35
+ var USER_CONFIG_DIR = join(homedir(), ".config", "lacode");
36
+ var USER_MODELS_PATH = join(USER_CONFIG_DIR, "pool.json");
37
+ var FALLBACK_SLOTS = [
38
+ { index: 1, model: "opencode/space-bunny-free", weight: 3 },
39
+ { index: 2, model: "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", weight: 2 },
40
+ { index: 3, model: "opencode/big-pickle", weight: 1 },
41
+ { index: 4, model: "openrouter/thinkingmachines/inkling:free", weight: 1 }
42
+ ];
43
+ function parseSlots(parsed, origin) {
44
+ const problems = [];
45
+ const entries = parsed?.slots;
46
+ if (!Array.isArray(entries) || entries.length === 0) {
47
+ return { slots: FALLBACK_SLOTS, problems: [`${origin} has no non-empty "slots" array; using built-in defaults`] };
48
+ }
49
+ const slots = [];
50
+ entries.forEach((entry, i) => {
51
+ const model = typeof entry?.model === "string" ? entry.model.trim() : "";
52
+ const at = `slot ${i + 1}`;
53
+ if (slots.length >= MAX_SLOTS) {
54
+ problems.push(`${at} (${model || "?"}): the pool is capped at ${MAX_SLOTS} slots (one variant agent per base per slot is declared); entry ignored`);
55
+ return;
56
+ }
57
+ if (!model.includes("/")) return problems.push(`${at}: model must be written provider/model-id, got "${model}"`);
58
+ const weight = readWeight(entry, at, model, problems);
59
+ if (slots.some((s) => s.model === model)) return problems.push(`${at}: ${model} is already used by another slot; slots must be distinct models`);
60
+ slots.push({ index: slots.length + 1, model, weight });
61
+ });
62
+ if (slots.length === 0) {
63
+ problems.push(`${origin} yielded no usable slot; using built-in defaults`);
64
+ return { slots: FALLBACK_SLOTS, problems };
65
+ }
66
+ return { slots, problems };
67
+ }
68
+ var readJSON = (path) => {
69
+ try {
70
+ return { ok: true, value: JSON.parse(readFileSync(path, "utf8")) };
71
+ } catch (error) {
72
+ return { ok: false, error: String(error) };
73
+ }
74
+ };
75
+ function readOptional(path, origin, problems) {
76
+ const result = readJSON(path);
77
+ if (!result.ok) {
78
+ if (existsSync(path)) problems.push(`${origin} unreadable (${result.error}); trying the next source`);
79
+ return void 0;
80
+ }
81
+ return result.value;
82
+ }
83
+ function resolveModels(input = {}) {
84
+ const problems = [];
85
+ if (input.models !== void 0) {
86
+ const origin = "plugin options";
87
+ if (typeof input.models !== "object" || input.models === null || Array.isArray(input.models)) {
88
+ return { slots: FALLBACK_SLOTS, problems: [`${origin}: "models" must be an object like {"slots":[...]}`], origin };
89
+ }
90
+ const parsed = parseSlots(input.models, origin);
91
+ return { slots: parsed.slots, problems: [...problems, ...parsed.problems], origin };
92
+ }
93
+ if (input.modelsFile) {
94
+ const result = readJSON(input.modelsFile);
95
+ if (!result.ok) return { slots: FALLBACK_SLOTS, problems: [`${input.modelsFile} unreadable (${result.error}); using built-in defaults`], origin: input.modelsFile };
96
+ const parsed = parseSlots(result.value, input.modelsFile);
97
+ return { slots: parsed.slots, problems: parsed.problems, origin: input.modelsFile };
98
+ }
99
+ const user = readOptional(USER_MODELS_PATH, USER_MODELS_PATH, problems);
100
+ if (user !== void 0) {
101
+ const parsed = parseSlots(user, USER_MODELS_PATH);
102
+ if (parsed.slots !== FALLBACK_SLOTS) return { slots: parsed.slots, problems, origin: USER_MODELS_PATH };
103
+ problems.push(...parsed.problems);
104
+ }
105
+ const preset = readOptional(DEFAULT_PRESET, DEFAULT_PRESET, problems);
106
+ if (preset !== void 0) {
107
+ const parsed = parseSlots(preset, "presets/free-tier.json");
108
+ return { slots: parsed.slots, problems: [...problems, ...parsed.problems], origin: "presets/free-tier.json" };
109
+ }
110
+ return { slots: FALLBACK_SLOTS, problems: [...problems, `no pool source found (${USER_MODELS_PATH}, ${DEFAULT_PRESET}); using built-in defaults`], origin: "built-in defaults" };
111
+ }
112
+ function readSlotFile(path) {
113
+ const result = readJSON(path);
114
+ if (!result.ok) return { slots: FALLBACK_SLOTS, problems: [`${path} unreadable (${result.error}); using built-in defaults`] };
115
+ return parseSlots(result.value, path);
116
+ }
117
+ function ensureUserConfigDir() {
118
+ try {
119
+ mkdirSync(USER_CONFIG_DIR, { recursive: true });
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+ function readWeight(entry, at, model, problems) {
126
+ const raw = entry?.weight;
127
+ if (raw === void 0) {
128
+ const legacy = entry?.tier;
129
+ if (legacy !== void 0) {
130
+ problems.push(
131
+ `weight: "tier" is deprecated; write "weight" instead (primary is weight 2, overflow is weight 1), got ${JSON.stringify(legacy)}`
132
+ );
133
+ if (legacy === "primary" || legacy === "overflow") return legacy === "primary" ? 2 : 1;
134
+ }
135
+ return 1;
136
+ }
137
+ const weight = typeof raw === "number" ? raw : Number(raw);
138
+ if (!Number.isFinite(weight) || weight <= 0 || weight > MAX_WEIGHT) {
139
+ problems.push(`${at} (${model}): weight must be a number in (0, ${MAX_WEIGHT}], got ${JSON.stringify(raw)}; using weight 1`);
140
+ return 1;
141
+ }
142
+ return weight;
143
+ }
144
+ var LIMIT_PATTERNS = [
145
+ /rate[\s_-]?limit/i,
146
+ /usage limit/i,
147
+ /too many requests/i,
148
+ /\b429\b/,
149
+ /quota/i,
150
+ /freelimiterror/i,
151
+ /overloaded/i
152
+ ];
153
+ var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text));
154
+ var statedCooldown = (text) => {
155
+ const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
156
+ if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
157
+ const m = /resets?\s+in\s+(\d+)\s*(?:m|min|minute)s?/i.exec(text);
158
+ if (m) return Number(m[1]) * 60 * 1e3;
159
+ const s = /retry[\s-]?in\s+(\d+)\s*(?:s|sec|second)s?/i.exec(text);
160
+ if (s) return Number(s[1]) * 1e3;
161
+ return void 0;
162
+ };
163
+ var retryAfterMs = (headers) => {
164
+ if (!headers) return void 0;
165
+ for (const [key, value] of Object.entries(headers)) {
166
+ if (key.toLowerCase() !== "retry-after") continue;
167
+ const seconds = Number(value);
168
+ if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds * 1e3, COOLDOWN_MAX_MS);
169
+ const when = Date.parse(value);
170
+ if (Number.isFinite(when)) return Math.min(Math.max(when - Date.now(), 1e3), COOLDOWN_MAX_MS);
171
+ }
172
+ return void 0;
173
+ };
174
+ function state(opts = {}) {
175
+ const id = opts.id ?? String(process.pid);
176
+ const dir = opts.dir ?? join(homedir(), ".local", "share", "opencode", "agent-pool");
177
+ const modelsFile = opts.modelsFile;
178
+ const models = opts.models;
179
+ const key = /* @__PURE__ */ Symbol.for(`agent-pool:state:${id}:${dir}:${modelsFile ?? ""}:${models === void 0 ? "" : "inline"}`);
180
+ const g = globalThis;
181
+ if (!g[key]) {
182
+ g[key] = {
183
+ own: /* @__PURE__ */ new Map(),
184
+ models: /* @__PURE__ */ new Map(),
185
+ known: /* @__PURE__ */ new Set(),
186
+ warned: /* @__PURE__ */ new Set(),
187
+ initialized: false,
188
+ dirReady: false,
189
+ catalog: /* @__PURE__ */ new Map(),
190
+ catalogAt: 0,
191
+ cooling: /* @__PURE__ */ new Map(),
192
+ activity: /* @__PURE__ */ new Map(),
193
+ reaped: 0,
194
+ reaping: false,
195
+ lastSweep: 0,
196
+ id,
197
+ dir,
198
+ modelsFile,
199
+ inlineModels: models,
200
+ origin: "unresolved",
201
+ slots: []
202
+ };
203
+ }
204
+ return g[key];
205
+ }
206
+ function ensureSlots(s, onWarn) {
207
+ if (s.slots.length > 0) return s.slots;
208
+ const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
209
+ s.slots = slots;
210
+ s.origin = origin;
211
+ for (const problem of problems) onWarn(problem);
212
+ return slots;
213
+ }
214
+ var claimKey = (sessionID, callID) => `${sessionID}:${callID}`;
215
+ var selfFile = (id) => `claims.${id}.json`;
216
+ var limitFile = (model) => `limit.${model.replace(/[^a-zA-Z0-9._-]/g, "__")}.json`;
217
+ function init(s, onWarn) {
218
+ ensureSlots(s, onWarn);
219
+ if (s.initialized) return;
220
+ s.initialized = true;
221
+ try {
222
+ mkdirSync(s.dir, { recursive: true });
223
+ s.dirReady = true;
224
+ try {
225
+ unlinkSync(join(s.dir, selfFile(s.id)));
226
+ } catch {
227
+ }
228
+ } catch {
229
+ onWarn(`claim dir ${s.dir} unusable; pool load tracked in memory only`);
230
+ }
231
+ }
232
+ function pruneOwn(s) {
233
+ const now = Date.now();
234
+ for (const [key, claim] of s.own) if (now - claim.t > CLAIM_TTL_MS) s.own.delete(key);
235
+ }
236
+ function publish(s) {
237
+ if (!s.dirReady) return;
238
+ const now = Date.now();
239
+ const payload = {};
240
+ for (const [key, claim] of s.own) if (now - claim.t <= CLAIM_TTL_MS) payload[key] = claim;
241
+ try {
242
+ writeFileSync(join(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
243
+ renameSync(join(s.dir, `${selfFile(s.id)}.tmp`), join(s.dir, selfFile(s.id)));
244
+ } catch {
245
+ s.dirReady = false;
246
+ }
247
+ }
248
+ function readSiblings(s) {
249
+ const siblings = [];
250
+ let entries;
251
+ try {
252
+ entries = readdirSync(s.dir);
253
+ } catch {
254
+ return siblings;
255
+ }
256
+ const now = Date.now();
257
+ for (const entry of entries) {
258
+ const path = join(s.dir, entry);
259
+ let age;
260
+ try {
261
+ age = now - statSync(path).mtimeMs;
262
+ } catch {
263
+ continue;
264
+ }
265
+ if (entry.endsWith(".tmp")) {
266
+ if (age > ORPHAN_TMP_MS) try {
267
+ unlinkSync(path);
268
+ } catch {
269
+ }
270
+ continue;
271
+ }
272
+ const match = /^claims\.(.+)\.json$/.exec(entry);
273
+ if (!match || match[1] === s.id) continue;
274
+ if (age > DEAD_FILE_MS) {
275
+ try {
276
+ unlinkSync(path);
277
+ } catch {
278
+ }
279
+ continue;
280
+ }
281
+ try {
282
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
283
+ const claims = Object.values(parsed).filter(
284
+ (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - c.t <= CLAIM_TTL_MS
285
+ );
286
+ siblings.push({ id: match[1], claims });
287
+ } catch {
288
+ }
289
+ }
290
+ return siblings;
291
+ }
292
+ function readLimits(s) {
293
+ const cooling = /* @__PURE__ */ new Map();
294
+ let entries;
295
+ try {
296
+ entries = readdirSync(s.dir);
297
+ } catch {
298
+ return cooling;
299
+ }
300
+ const now = Date.now();
301
+ for (const entry of entries) {
302
+ if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
303
+ const path = join(s.dir, entry);
304
+ try {
305
+ const strike = JSON.parse(readFileSync(path, "utf8"));
306
+ if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
307
+ if (strike.until > now) cooling.set(strike.model, strike);
308
+ else unlinkSync(path);
309
+ } catch {
310
+ try {
311
+ unlinkSync(path);
312
+ } catch {
313
+ }
314
+ }
315
+ }
316
+ s.cooling = cooling;
317
+ return cooling;
318
+ }
319
+ function recordStrike(s, model, reason, stated) {
320
+ if (!s.dirReady) return;
321
+ const now = Date.now();
322
+ const prior = s.cooling.get(model);
323
+ const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
324
+ const backoff = Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * 2 ** (strikes - 1));
325
+ const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
326
+ s.cooling.set(model, strike);
327
+ const path = join(s.dir, limitFile(model));
328
+ try {
329
+ writeFileSync(`${path}.tmp`, JSON.stringify(strike));
330
+ renameSync(`${path}.tmp`, path);
331
+ } catch {
332
+ }
333
+ return strike;
334
+ }
335
+ function refreshCatalog(s, serverUrl) {
336
+ if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
337
+ s.catalogAt = Date.now();
338
+ void (async () => {
339
+ try {
340
+ const response = await fetch(new URL("/api/model", serverUrl).toString());
341
+ if (!response.ok) return;
342
+ const payload = await response.json();
343
+ const catalog = /* @__PURE__ */ new Map();
344
+ for (const m of payload.data ?? []) catalog.set(`${m.providerID}/${m.id}`, m.status ?? "unknown");
345
+ if (catalog.size > 0) s.catalog = catalog;
346
+ } catch {
347
+ }
348
+ })();
349
+ }
350
+ function isAvailable(s, model, cooling) {
351
+ if (cooling.has(model)) return false;
352
+ const status = s.catalog.get(model);
353
+ return status === void 0 || status === "active" || status === "unknown";
354
+ }
355
+ function snapshot(s) {
356
+ pruneOwn(s);
357
+ const siblings = readSiblings(s);
358
+ const cooling = readLimits(s);
359
+ const counts = /* @__PURE__ */ new Map();
360
+ const bump = (m) => counts.set(m, (counts.get(m) ?? 0) + 1);
361
+ for (const claim of s.own.values()) bump(claim.m);
362
+ for (const sibling of siblings) for (const claim of sibling.claims) bump(claim.m);
363
+ return { counts, cooling, siblings, own: s.own, dirReady: s.dirReady };
364
+ }
365
+ var loadOf = (counts, model) => counts.get(model) ?? 0;
366
+ var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot.weight;
367
+ function pickSlot(s, snap) {
368
+ const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
369
+ const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
370
+ const pool = usable.length > 0 ? usable : slots;
371
+ const open = pool.filter((slot) => underCeiling(slot, snap.counts));
372
+ if (open.length > 0) return open.reduce((best, slot) => slot.weight > best.weight ? slot : best, open[0]);
373
+ return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
374
+ }
375
+ function agentFor(base, slot) {
376
+ return `${base}-${slot}`;
377
+ }
378
+ function acquire(s, base, slot, ownerSession, key, target) {
379
+ s.own.set(key, { v: base, k: slot.index, m: slot.model, t: Date.now(), s: ownerSession, g: target });
380
+ if (target) s.activity.set(target, Date.now());
381
+ publish(s);
382
+ }
383
+ function release(s, key) {
384
+ if (s.own.delete(key)) publish(s);
385
+ }
386
+ function bindChild(s, parentID, childID) {
387
+ const candidates = [...s.own.entries()].filter(([, c]) => c.s === parentID && !c.g).sort((a, b) => a[1].t - b[1].t);
388
+ if (candidates.length === 0) return false;
389
+ candidates[0][1].g = childID;
390
+ s.activity.set(childID, Date.now());
391
+ publish(s);
392
+ return true;
393
+ }
394
+ function noteActivity(s, sessionID) {
395
+ s.activity.set(sessionID, Date.now());
396
+ }
397
+ function idleFor(s, claim, now) {
398
+ const target = claim.g;
399
+ if (!target) return Infinity;
400
+ const seen = s.activity.get(target);
401
+ return seen ? now - seen : Infinity;
402
+ }
403
+ function findStuck(s, now = Date.now()) {
404
+ const stuck = [];
405
+ for (const [key, claim] of s.own) {
406
+ if (now - claim.t <= STUCK_MIN_AGE_MS) continue;
407
+ if (idleFor(s, claim, now) < STUCK_IDLE_MS) continue;
408
+ stuck.push({ key, claim });
409
+ }
410
+ return stuck;
411
+ }
412
+ function releaseStuck(s, keys) {
413
+ for (const key of keys) s.own.delete(key);
414
+ if (keys.length > 0) publish(s);
415
+ }
416
+ function slotFor(s, model) {
417
+ return s.slots.find((slot) => slot.model === model);
418
+ }
419
+ function splitModel(model) {
420
+ const slash = model.indexOf("/");
421
+ if (slash < 0) return { provider: model, id: "" };
422
+ return { provider: model.slice(0, slash), id: model.slice(slash + 1) };
423
+ }
424
+ var providerOf = (model) => splitModel(model).provider;
425
+ function coolProvider(s, provider, until, reason) {
426
+ const affected = [];
427
+ for (const slot of s.slots.length > 0 ? s.slots : FALLBACK_SLOTS) {
428
+ if (providerOf(slot.model) !== provider) continue;
429
+ const existing = s.cooling.get(slot.model);
430
+ const untilMs = Math.max(until, existing?.until ?? 0);
431
+ const strike = recordStrike(s, slot.model, reason, untilMs - Date.now());
432
+ if (strike) affected.push(strike);
433
+ }
434
+ return affected;
435
+ }
436
+ export {
437
+ BASES,
438
+ CATALOG_TTL_MS,
439
+ CLAIM_TTL_MS,
440
+ DEAD_FILE_MS,
441
+ DEFAULT_PRESET,
442
+ FALLBACK_SLOTS,
443
+ HANG_COOLDOWN_MS,
444
+ MAX_SLOTS,
445
+ MAX_WEIGHT,
446
+ ORPHAN_TMP_MS,
447
+ PACKAGE_ROOT,
448
+ PRESETS_DIR,
449
+ STUCK_IDLE_MS,
450
+ STUCK_MIN_AGE_MS,
451
+ USER_CONFIG_DIR,
452
+ USER_MODELS_PATH,
453
+ acquire,
454
+ agentFor,
455
+ bindChild,
456
+ claimKey,
457
+ coolProvider,
458
+ ensureSlots,
459
+ ensureUserConfigDir,
460
+ findStuck,
461
+ idleFor,
462
+ init,
463
+ isAvailable,
464
+ isLimitText,
465
+ noteActivity,
466
+ parseSlots,
467
+ pickSlot,
468
+ providerOf,
469
+ pruneOwn,
470
+ publish,
471
+ readLimits,
472
+ readSiblings,
473
+ readSlotFile,
474
+ recordStrike,
475
+ refreshCatalog,
476
+ release,
477
+ releaseStuck,
478
+ resolveModels,
479
+ retryAfterMs,
480
+ selfFile,
481
+ slotFor,
482
+ snapshot,
483
+ splitModel,
484
+ state,
485
+ statedCooldown,
486
+ underCeiling
487
+ };
package/dist/tunnel.js ADDED
@@ -0,0 +1,180 @@
1
+ // src/tunnel/index.ts
2
+ import { spawnSync } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ var ICONS = [
6
+ "utilities-terminal",
7
+ "dialog-information",
8
+ "dialog-error",
9
+ "dialog-warning",
10
+ "system-run",
11
+ "emblem-default"
12
+ ];
13
+ function resolveTunnel(env = process.env) {
14
+ const port = Number(env.OC_TUNNEL_PORT);
15
+ return {
16
+ user: env.OC_LOCAL_USER ?? "",
17
+ port: Number.isFinite(port) && port > 0 ? port : 10022,
18
+ key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
19
+ connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
20
+ };
21
+ }
22
+ function isRemote(env = process.env) {
23
+ return Boolean(env.SSH_CLIENT || env.SSH_TTY);
24
+ }
25
+ function normalizeUrgency(raw) {
26
+ return raw === "low" || raw === "critical" || raw === "normal" ? raw : "normal";
27
+ }
28
+ function normalizeIcon(raw) {
29
+ return ICONS.includes(raw) ? raw : "utilities-terminal";
30
+ }
31
+ var safeText = (raw, fallback, max) => {
32
+ const clean = raw.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
33
+ if (!clean) return fallback;
34
+ return clean.length > max ? `${clean.slice(0, max - 1)}\u2026` : clean;
35
+ };
36
+ var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
37
+ var safeMessage = (raw) => safeText(raw ?? "", "", 400);
38
+ var FIELD_SEPARATOR = "\n";
39
+ var encodePayload = (request) => [
40
+ safeTitle(request.title),
41
+ safeMessage(request.message),
42
+ normalizeUrgency(request.urgency),
43
+ normalizeIcon(request.icon)
44
+ ].join(FIELD_SEPARATOR);
45
+ var SSH_OPTS = [
46
+ "-o",
47
+ "BatchMode=yes",
48
+ "-o",
49
+ "IdentitiesOnly=yes",
50
+ "-o",
51
+ "ClearAllForwardings=yes",
52
+ "-o",
53
+ "LogLevel=ERROR",
54
+ "-o",
55
+ "StrictHostKeyChecking=accept-new"
56
+ ];
57
+ var run = (command, args, timeout) => {
58
+ try {
59
+ const result = spawnSync(command, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
60
+ return {
61
+ ok: result.status === 0,
62
+ stdout: result.stdout ?? "",
63
+ stderr: (result.stderr ?? "").trim(),
64
+ // spawnSync reports a timeout as a null status and an ETIMEDOUT error; both
65
+ // are "did not arrive", not "crashed", and neither may throw.
66
+ detail: result.error ? String(result.error.code ?? result.error.message) : void 0
67
+ };
68
+ } catch (error) {
69
+ return { ok: false, stdout: "", stderr: "", detail: String(error) };
70
+ }
71
+ };
72
+ var have = (command) => {
73
+ try {
74
+ return spawnSync("sh", ["-c", `command -v ${command}`], { encoding: "utf8" }).status === 0;
75
+ } catch {
76
+ return false;
77
+ }
78
+ };
79
+ var CHANNEL_BINARY = {
80
+ "notify-send": "notify-send",
81
+ osascript: "osascript",
82
+ "terminal-notifier": "terminal-notifier"
83
+ };
84
+ var DARWIN_SCRIPT = [
85
+ "on run argv",
86
+ " display notification (item 3 of argv) with title (item 2 of argv)",
87
+ "end run"
88
+ ].join("\n");
89
+ var DARWIN_ARGV_MARKER = "lacode";
90
+ function localChannel(platform = process.platform) {
91
+ if (platform === "darwin") {
92
+ if (have(CHANNEL_BINARY.osascript)) return "osascript";
93
+ if (have(CHANNEL_BINARY["terminal-notifier"])) return "terminal-notifier";
94
+ return "none";
95
+ }
96
+ return have(CHANNEL_BINARY["notify-send"]) ? "notify-send" : "none";
97
+ }
98
+ var NO_CHANNEL = {
99
+ linux: "notify-send is not installed",
100
+ darwin: "neither osascript nor terminal-notifier is installed"
101
+ };
102
+ function sendLocal(request, platform = process.platform) {
103
+ const channel = localChannel(platform);
104
+ if (channel === "none") {
105
+ return { ok: false, detail: NO_CHANNEL[platform] ?? `no local notification channel on ${platform}`, transport: "local" };
106
+ }
107
+ const title = safeTitle(request.title);
108
+ const body = safeMessage(request.message);
109
+ 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)] : (
110
+ // Byte-identical to the notify-send call this package has always made.
111
+ [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
112
+ );
113
+ const result = run(CHANNEL_BINARY[channel], args, 5e3);
114
+ return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
115
+ }
116
+ function sendRemote(request, tunnel) {
117
+ if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
118
+ const args = [
119
+ ...SSH_OPTS,
120
+ "-o",
121
+ `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`,
122
+ "-i",
123
+ tunnel.key,
124
+ "-p",
125
+ String(tunnel.port),
126
+ `${tunnel.user}@localhost`,
127
+ "--",
128
+ encodePayload(request)
129
+ ];
130
+ const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
131
+ return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
132
+ }
133
+ function notify(request, env = process.env, platform = process.platform) {
134
+ try {
135
+ return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
136
+ } catch (error) {
137
+ return { ok: false, detail: String(error), transport: "none" };
138
+ }
139
+ }
140
+ function forward(host, argv, options = {}) {
141
+ const env = options.env ?? process.env;
142
+ const timeoutMs = options.timeoutMs ?? (Number(env.OC_SSH_TIMEOUT_MS) || 3e4);
143
+ const result = run("ssh", [...SSH_OPTS, "-o", `ConnectTimeout=${Math.max(1, Math.round(timeoutMs / 1e3))}`, host, "--", ...argv], timeoutMs);
144
+ return {
145
+ ok: result.ok,
146
+ detail: result.ok ? void 0 : result.stderr || result.detail,
147
+ transport: "remote",
148
+ stdout: result.stdout
149
+ };
150
+ }
151
+ function describe(env = process.env, platform = process.platform) {
152
+ const tunnel = resolveTunnel(env);
153
+ return {
154
+ running: isRemote(env) ? "remote (over ssh)" : "local",
155
+ user: tunnel.user || "(unset)",
156
+ port: tunnel.port,
157
+ key: tunnel.key,
158
+ channel: localChannel(platform),
159
+ // Still exactly what it always meant -- is the notify-send binary present --
160
+ // rather than a second opinion about which channel runs.
161
+ notifySend: have("notify-send")
162
+ };
163
+ }
164
+ export {
165
+ FIELD_SEPARATOR,
166
+ ICONS,
167
+ describe,
168
+ encodePayload,
169
+ forward,
170
+ isRemote,
171
+ localChannel,
172
+ normalizeIcon,
173
+ normalizeUrgency,
174
+ notify,
175
+ resolveTunnel,
176
+ safeMessage,
177
+ safeTitle,
178
+ sendLocal,
179
+ sendRemote
180
+ };