@lawaty/lacode 0.2.1 → 0.3.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 +38 -9
- package/dist/commands.js +30 -0
- package/dist/index.js +45 -9
- package/dist/lacode.js +84 -36
- package/dist/tunnel.js +76 -8
- package/docs/TUNNEL.md +37 -10
- package/docs/USER-STORIES.md +13 -0
- package/package.json +2 -1
- 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/README.md
CHANGED
|
@@ -284,6 +284,7 @@ option is optional:
|
|
|
284
284
|
| `modelsFile` | — | An explicit slot file; short-circuits the chain below. |
|
|
285
285
|
| `reapMs` | `30000` | Hang-reaper interval. |
|
|
286
286
|
| `selfUpdate` | `true` | Check for a newer release at most once per ~6h and install it in the background; it applies on the next opencode start. `false` — or `LACODE_NO_SELFUPDATE=1` — turns the check off. |
|
|
287
|
+
| `sound` | `true` | Play a short tone with each notification, one per outcome, so you can tell a finished session from a question or a permission prompt without reading the popup. It is opt-out: `false` — or `LACODE_SOUND=0` (`false`, `off`, `no` work too, and beat the option) — turns the sound off, and anything else, a typo included, leaves it on. |
|
|
287
288
|
| `omniroute` | — | Optional [OmniRoute](#configuring-omniroute) gateway integration. Absent or `{ enabled: false }` changes nothing. |
|
|
288
289
|
|
|
289
290
|
### Staying up to date
|
|
@@ -324,6 +325,29 @@ request across many providers with fallback and "combos". LaCode can register it
|
|
|
324
325
|
as an opencode provider for you. It is **off by default**: if you do not set
|
|
325
326
|
`omniroute.enabled: true`, nothing below happens and LaCode makes no request.
|
|
326
327
|
|
|
328
|
+
**Setting it up on a client machine.** Four steps, in this order:
|
|
329
|
+
|
|
330
|
+
1. Install OmniRoute, then make it stay up: run `omniroute autostart enable`
|
|
331
|
+
once. That writes a systemd user unit in a headless session, or an XDG
|
|
332
|
+
autostart entry in a graphical one, and OmniRoute supervises itself from
|
|
333
|
+
there on. Its gateway listens on `http://localhost:20128/v1`. The gateway's
|
|
334
|
+
lifecycle is **the client's responsibility**: LaCode never starts, supervises
|
|
335
|
+
or stops it, it only probes it. The gateway must therefore already be
|
|
336
|
+
running **before** you start opencode — one that is down at startup is
|
|
337
|
+
disabled for that run, and the integration is per-run.
|
|
338
|
+
2. Add the plugin block to your opencode config:
|
|
339
|
+
`["@lawaty/lacode", { "omniroute": { "enabled": true, "apiKey": "<your omniroute key>", "combo": "auto", "models": [] } }]`
|
|
340
|
+
3. Optionally, to route pooled work through it, make `omniroute/auto 3` the
|
|
341
|
+
**first** line of `~/.config/lacode/pool.txt`. `pool.txt` is optional user
|
|
342
|
+
config; the bundled default preset does **not** include OmniRoute.
|
|
343
|
+
4. **Restart opencode.** Nothing here hot-reloads.
|
|
344
|
+
|
|
345
|
+
With `models: []` and `combo: "auto"`, the plugin registers exactly one model,
|
|
346
|
+
`omniroute/auto`, which then appears in the model picker for any agent — no
|
|
347
|
+
hand-maintained model list.
|
|
348
|
+
|
|
349
|
+
The full option set, for reference:
|
|
350
|
+
|
|
327
351
|
```jsonc
|
|
328
352
|
{
|
|
329
353
|
"plugin": [
|
|
@@ -357,7 +381,7 @@ as an opencode provider for you. It is **off by default**: if you do not set
|
|
|
357
381
|
| `tiers` | — | `{ "primary": [...], "backup": [...] }` preference order over models the allowlist already permits. |
|
|
358
382
|
| `stickyPerSession` | `true` | Keep a session on one model while that model is healthy. |
|
|
359
383
|
| `healthCheck` | `true` | Probe `GET /models` at startup; a failed check disables the integration for that run only. |
|
|
360
|
-
| `timeoutMs` | `5000` | Budget for LaCode's own calls to the gateway. |
|
|
384
|
+
| `timeoutMs` | `5000` | Budget for LaCode's own probe and model-cache calls to the gateway. It is not written into the provider options, so it does not bound real traffic — opencode's own default applies there. |
|
|
361
385
|
|
|
362
386
|
An enabled but invalid block (bad `baseURL`, a non-array `models`, …) is rejected
|
|
363
387
|
as a whole: LaCode logs one warning naming the problem and continues exactly as
|
|
@@ -372,12 +396,14 @@ already have a `provider.omniroute` block, LaCode leaves it completely untouched
|
|
|
372
396
|
and warns; it also never adds a `whitelist`. Because opencode loads
|
|
373
397
|
`@ai-sdk/openai-compatible` itself, no extra dependency is installed.
|
|
374
398
|
|
|
375
|
-
**Combo vs. allowlist.** Set `combo` and OmniRoute does the fallback for you
|
|
376
|
-
it route)
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
399
|
+
**Combo vs. allowlist.** Set `combo` and OmniRoute does the fallback for you
|
|
400
|
+
(let it route): the combo leads the exposed models, so opencode can hand a failed
|
|
401
|
+
request to the gateway's own fallback chain. Without a combo, LaCode exposes the
|
|
402
|
+
allowlist ordered by `tiers` (primary first). Candidate order is `filterModels`
|
|
403
|
+
(combo first, then the hard allowlist) followed by `orderByTier`, and that is
|
|
404
|
+
all — LaCode picks which model is exposed and in what order; it does not measure
|
|
405
|
+
latency or error rate at runtime, and it does not skip a model because it
|
|
406
|
+
failed.
|
|
381
407
|
|
|
382
408
|
**Using OmniRoute in the pool.** The integration only *registers* the provider —
|
|
383
409
|
it never touches your pool, and it does not place a slot for you. To route pooled
|
|
@@ -415,7 +441,9 @@ local instance with `REQUIRE_API_KEY=false`, the literal placeholder
|
|
|
415
441
|
|
|
416
442
|
| Symptom | Cause | Fix |
|
|
417
443
|
| --- | --- | --- |
|
|
418
|
-
| `omniroute health check failed` at startup | Gateway not running, wrong `baseURL`, or a startup race | Start OmniRoute on `http://localhost:20128` and restart opencode. The integration is disabled for that run only, not permanently. |
|
|
444
|
+
| `omniroute health check failed` at startup | Gateway not running, wrong `baseURL`, or a startup race | Start OmniRoute on `http://localhost:20128` and restart opencode; `omniroute autostart enable` makes that durable across restarts. The integration is disabled for that run only, not permanently. |
|
|
445
|
+
| `omniroute health check failed` at startup, then a repeating `cooling omniroute/<id> (hang)` and `Model not found` churn on one slot | OmniRoute was started *after* opencode, and `pool.txt` names an `omniroute/<id>` slot | The integration is off for that run, but the pool still routes to the slot: the request hangs, the reaper cools it for 600s, and the cycle repeats. Start OmniRoute, then restart opencode. |
|
|
446
|
+
| `ProviderModelNotFoundError: Model not found` on an id the gateway serves | The id is a valid OmniRoute route but is not in the plugin's `models` allowlist, so the plugin never exposed it | Add the id to `models`, or use `combo: "auto"` with `omniroute/auto`. A valid gateway route is not automatically an exposed model. |
|
|
419
447
|
| `no usable models` | The gateway returned an empty list, or every id was filtered out by `models` | `curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" http://localhost:20128/v1/models` and check `models`/`combo` against the returned ids. |
|
|
420
448
|
| `401` / `Invalid API key` | `REQUIRE_API_KEY=true` and the key is missing or wrong | Create a key in the dashboard, export `OMNIROUTE_API_KEY`, or set `REQUIRE_API_KEY=false` locally and use `sk_omniroute`. |
|
|
421
449
|
| `404` on a URL containing `/v1/v1/` | A `baseURL` that already carried `/v1` was given twice | LaCode normalises this, so it should not come from LaCode; check a hand-written `provider.omniroute` block, which LaCode leaves untouched. |
|
|
@@ -531,6 +559,7 @@ are reading:
|
|
|
531
559
|
/context-init # build the map, or verify and refresh an existing one
|
|
532
560
|
/context-update # incremental refresh; accepts a focus, e.g. /context-update src/payments
|
|
533
561
|
/context-review # audit it for staleness, contradictions, duplication and bloat
|
|
562
|
+
/lacode-doctor # check the pool, model resolution and the OmniRoute gateway. Report only
|
|
534
563
|
```
|
|
535
564
|
|
|
536
565
|
The map is local and untracked — it describes your checkout on your machine, so
|
|
@@ -617,7 +646,7 @@ open are in [docs/TUNNEL.md](docs/TUNNEL.md).
|
|
|
617
646
|
|
|
618
647
|
## User stories
|
|
619
648
|
|
|
620
|
-
Twenty-
|
|
649
|
+
Twenty-nine stories across seven epics, each written as a behavioural contract
|
|
621
650
|
with acceptance criteria a test can assert mechanically — the pains behind them,
|
|
622
651
|
the Given/When/Then criteria, and the story-to-test mapping are in
|
|
623
652
|
[docs/USER-STORIES.md](docs/USER-STORIES.md).
|
package/dist/commands.js
CHANGED
|
@@ -67,6 +67,36 @@ Audit the five context files for:
|
|
|
67
67
|
|
|
68
68
|
Fix what you find in \`.opencode/context/\` only, then report: findings, fixes,
|
|
69
69
|
and anything you could not verify.`
|
|
70
|
+
},
|
|
71
|
+
"lacode-doctor": {
|
|
72
|
+
description: "Check the LaCode setup \u2014 pool slots, model resolution, and the OmniRoute gateway. Report only; changes nothing.",
|
|
73
|
+
template: `Diagnose this machine's LaCode setup. Report only; change nothing.
|
|
74
|
+
|
|
75
|
+
Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
|
|
76
|
+
|
|
77
|
+
1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
|
|
78
|
+
slot's load against its weight ceiling, and which models are cooling.
|
|
79
|
+
|
|
80
|
+
2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
|
|
81
|
+
user's opencode config for an \`omniroute\` block with \`enabled: true\`. If it
|
|
82
|
+
is absent or disabled, print the two OmniRoute checks below as "not
|
|
83
|
+
configured" and stop after the pool section.
|
|
84
|
+
|
|
85
|
+
3. Gateway reachable \u2014 when omniroute is configured, request its model list
|
|
86
|
+
(\`GET <baseURL>/v1/models\`, default baseURL \`http://localhost:20128/v1\`)
|
|
87
|
+
and report reachable or not. On failure, say plainly: start OmniRoute BEFORE
|
|
88
|
+
opencode, then restart opencode \u2014 provider registration is per-run. To make
|
|
89
|
+
that durable, run \`omniroute autostart enable\` (OmniRoute's own autostart;
|
|
90
|
+
LaCode never starts the gateway itself).
|
|
91
|
+
|
|
92
|
+
4. Slots resolve \u2014 for every pool slot whose provider is \`omniroute\`, confirm
|
|
93
|
+
the model id is one the gateway advertises AND is registered. The known
|
|
94
|
+
trap: an id that is a valid gateway route but is NOT in the plugin's
|
|
95
|
+
\`omniroute.models\` allowlist fails with
|
|
96
|
+
\`ProviderModelNotFoundError: Model not found\`; the fix is to add that id to
|
|
97
|
+
the \`models\` array, or use the always-registered \`auto\`.
|
|
98
|
+
|
|
99
|
+
Change nothing. Print a final one-line summary of failures.`
|
|
70
100
|
}
|
|
71
101
|
};
|
|
72
102
|
function applyCommands(cfg, commands = COMMANDS) {
|
package/dist/index.js
CHANGED
|
@@ -1272,6 +1272,36 @@ Audit the five context files for:
|
|
|
1272
1272
|
|
|
1273
1273
|
Fix what you find in \`.opencode/context/\` only, then report: findings, fixes,
|
|
1274
1274
|
and anything you could not verify.`
|
|
1275
|
+
},
|
|
1276
|
+
"lacode-doctor": {
|
|
1277
|
+
description: "Check the LaCode setup \u2014 pool slots, model resolution, and the OmniRoute gateway. Report only; changes nothing.",
|
|
1278
|
+
template: `Diagnose this machine's LaCode setup. Report only; change nothing.
|
|
1279
|
+
|
|
1280
|
+
Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
|
|
1281
|
+
|
|
1282
|
+
1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
|
|
1283
|
+
slot's load against its weight ceiling, and which models are cooling.
|
|
1284
|
+
|
|
1285
|
+
2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
|
|
1286
|
+
user's opencode config for an \`omniroute\` block with \`enabled: true\`. If it
|
|
1287
|
+
is absent or disabled, print the two OmniRoute checks below as "not
|
|
1288
|
+
configured" and stop after the pool section.
|
|
1289
|
+
|
|
1290
|
+
3. Gateway reachable \u2014 when omniroute is configured, request its model list
|
|
1291
|
+
(\`GET <baseURL>/v1/models\`, default baseURL \`http://localhost:20128/v1\`)
|
|
1292
|
+
and report reachable or not. On failure, say plainly: start OmniRoute BEFORE
|
|
1293
|
+
opencode, then restart opencode \u2014 provider registration is per-run. To make
|
|
1294
|
+
that durable, run \`omniroute autostart enable\` (OmniRoute's own autostart;
|
|
1295
|
+
LaCode never starts the gateway itself).
|
|
1296
|
+
|
|
1297
|
+
4. Slots resolve \u2014 for every pool slot whose provider is \`omniroute\`, confirm
|
|
1298
|
+
the model id is one the gateway advertises AND is registered. The known
|
|
1299
|
+
trap: an id that is a valid gateway route but is NOT in the plugin's
|
|
1300
|
+
\`omniroute.models\` allowlist fails with
|
|
1301
|
+
\`ProviderModelNotFoundError: Model not found\`; the fix is to add that id to
|
|
1302
|
+
the \`models\` array, or use the always-registered \`auto\`.
|
|
1303
|
+
|
|
1304
|
+
Change nothing. Print a final one-line summary of failures.`
|
|
1275
1305
|
}
|
|
1276
1306
|
};
|
|
1277
1307
|
function applyCommands(cfg, commands = COMMANDS) {
|
|
@@ -1875,15 +1905,16 @@ var INTERNAL_TITLE_PREFIXES = [
|
|
|
1875
1905
|
// it would announce the cartographer's own bookkeeping as a finished task.
|
|
1876
1906
|
"context (auto"
|
|
1877
1907
|
];
|
|
1908
|
+
var NOTIFY_OUTCOMES = ["done", "question", "permission", "error"];
|
|
1878
1909
|
var notifyMessage = (outcome, detail) => {
|
|
1879
1910
|
if (outcome === "error") return detail ? `Failed \u2014 ${detail}` : "Failed";
|
|
1880
1911
|
if (outcome === "question") return detail ? `Awaiting your response \u2014 ${detail}` : "Awaiting your response";
|
|
1881
1912
|
if (outcome === "permission") return detail ? `Awaiting your permission \u2014 ${detail}` : "Awaiting your permission";
|
|
1882
1913
|
return "Task finished, waiting for your review";
|
|
1883
1914
|
};
|
|
1884
|
-
var helperArgv = (helper, title, message, urgency, icon) => {
|
|
1915
|
+
var helperArgv = (helper, title, message, urgency, icon, sound) => {
|
|
1885
1916
|
const name = helper.split(/[\\/]/).pop() ?? "";
|
|
1886
|
-
return name === "lacode" || name === "lacode.js" ? ["notify", title, message, urgency, icon] : [title, message, urgency, icon];
|
|
1917
|
+
return name === "lacode" || name === "lacode.js" ? ["notify", title, message, urgency, icon, sound] : [title, message, urgency, icon, sound];
|
|
1887
1918
|
};
|
|
1888
1919
|
var safeStringify = (value) => {
|
|
1889
1920
|
try {
|
|
@@ -2038,6 +2069,8 @@ var Notifier = class {
|
|
|
2038
2069
|
// src/plugins/notify.ts
|
|
2039
2070
|
var SERVICE3 = "notify";
|
|
2040
2071
|
var COOLDOWN_MS2 = 5e3;
|
|
2072
|
+
var SOUND_ENV = "LACODE_SOUND";
|
|
2073
|
+
var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
|
|
2041
2074
|
var findHelper = () => {
|
|
2042
2075
|
const home = process.env.HOME ?? "";
|
|
2043
2076
|
const candidates = [
|
|
@@ -2051,7 +2084,10 @@ var findHelper = () => {
|
|
|
2051
2084
|
for (const candidate of candidates) if (existsSync3(candidate)) return candidate;
|
|
2052
2085
|
return void 0;
|
|
2053
2086
|
};
|
|
2054
|
-
var notifyHooks = (async ({ client, directory },
|
|
2087
|
+
var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
2088
|
+
const opts = options ?? {};
|
|
2089
|
+
const soundEnv = (process.env[SOUND_ENV] ?? "").trim().toLowerCase();
|
|
2090
|
+
const soundOn = !SOUND_OFF_VALUES.includes(soundEnv) && opts.sound !== false;
|
|
2055
2091
|
const notifier = new Notifier(COOLDOWN_MS2);
|
|
2056
2092
|
const active = /* @__PURE__ */ new Set();
|
|
2057
2093
|
const titles = /* @__PURE__ */ new Map();
|
|
@@ -2064,7 +2100,7 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
|
|
|
2064
2100
|
const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
|
|
2065
2101
|
return info;
|
|
2066
2102
|
};
|
|
2067
|
-
const deliverViaHelper = (title, message, urgency) => new Promise((resolve3) => {
|
|
2103
|
+
const deliverViaHelper = (title, message, urgency, sound) => new Promise((resolve3) => {
|
|
2068
2104
|
const helper = findHelper();
|
|
2069
2105
|
if (!helper) {
|
|
2070
2106
|
if (!warnedHelper) {
|
|
@@ -2074,7 +2110,7 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
|
|
|
2074
2110
|
resolve3({ ok: false, detail: "no oc-notify helper found" });
|
|
2075
2111
|
return;
|
|
2076
2112
|
}
|
|
2077
|
-
const child = spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal"), {
|
|
2113
|
+
const child = spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
|
|
2078
2114
|
stdio: ["ignore", "ignore", "pipe"],
|
|
2079
2115
|
detached: true
|
|
2080
2116
|
});
|
|
@@ -2133,7 +2169,8 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
|
|
|
2133
2169
|
return;
|
|
2134
2170
|
}
|
|
2135
2171
|
const message = isRemote ? `${decision.message} (remote)` : decision.message;
|
|
2136
|
-
const
|
|
2172
|
+
const sound = soundOn && NOTIFY_OUTCOMES.includes(outcome) ? outcome : "none";
|
|
2173
|
+
const sent = await deliver(decision.title, message, decision.urgency, sound);
|
|
2137
2174
|
void log(sent.ok ? "info" : "error", sent.ok ? "notified" : "notification failed", {
|
|
2138
2175
|
sessionID,
|
|
2139
2176
|
outcome,
|
|
@@ -2617,7 +2654,7 @@ var omniRouteHooks = async (input, options, deps = {}) => {
|
|
|
2617
2654
|
});
|
|
2618
2655
|
if (!healthy) {
|
|
2619
2656
|
disabled = true;
|
|
2620
|
-
await warnOnce(`omniroute health check failed at ${config.baseURL}; falling back to lacode's normal behaviour for this run`);
|
|
2657
|
+
await warnOnce(`omniroute health check failed at ${config.baseURL}; falling back to lacode's normal behaviour for this run; pool slots naming omniroute/<id> will fail until the gateway is up`);
|
|
2621
2658
|
return;
|
|
2622
2659
|
}
|
|
2623
2660
|
}
|
|
@@ -2667,8 +2704,7 @@ var omniRouteHooks = async (input, options, deps = {}) => {
|
|
|
2667
2704
|
baseURL: config.baseURL,
|
|
2668
2705
|
// Absent rather than blank: an empty bearer on a keyless gateway is a
|
|
2669
2706
|
// 401 that looks like a configuration error.
|
|
2670
|
-
...config.apiKey ? { apiKey: config.apiKey } : {}
|
|
2671
|
-
timeout: config.timeoutMs
|
|
2707
|
+
...config.apiKey ? { apiKey: config.apiKey } : {}
|
|
2672
2708
|
},
|
|
2673
2709
|
models
|
|
2674
2710
|
};
|
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/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`
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -222,6 +222,18 @@ does not.
|
|
|
222
222
|
- [ ] Given the plugin observes a permission prompt, When it handles the event, Then it is a read-only observer: the payload is not mutated and no `permission.ask` hook is registered, because that hook's `output.status` is the permission decision itself.
|
|
223
223
|
- [ ] Given a `permission` notification is raised, When it is evaluated against the cooldown, Then it bypasses it, since an unannounced block is the failure this story exists to remove.
|
|
224
224
|
|
|
225
|
+
### US-29: Notification sounds distinguish outcomes and can be switched off
|
|
226
|
+
**As a** user running many parallel sessions, **I want** each notification to arrive with its own sound, **so that** I can tell a finished session from a question or a permission prompt without looking at the popup.
|
|
227
|
+
|
|
228
|
+
**Story ID(s) for tests:** US-29
|
|
229
|
+
**Acceptance criteria:**
|
|
230
|
+
- [ ] Given any of the four outcomes, When its notification is delivered, Then it carries that outcome's own sound name — `done`, `question`, `permission` or `error` — and no two share one, so the four are distinguishable by ear alone.
|
|
231
|
+
- [ ] Given a notification raised on a remote host, When it reaches the desktop, Then the sound plays where the notification is delivered and never on the opencode host, because a headless host has no speakers for you to hear.
|
|
232
|
+
- [ ] Given no sound player is installed, or the player cannot start, When the notification is delivered, Then the notification is delivered unchanged and only the sound is skipped, because a missing audio tool must never cost you the alert itself.
|
|
233
|
+
- [ ] Given a machine with no notification channel at all, When a notification is raised, Then its sound still plays, because on such a desktop the tone is the only alert there is.
|
|
234
|
+
- [ ] Given the plugin option `sound` is `false`, or `LACODE_SOUND` is `0`, `false`, `off` or `no`, When a notification is delivered, Then its sound is `none`; when both are set the environment wins over the option, and any other value — a typo included — leaves sound on, because a misspelt variable must not be how you get silence.
|
|
235
|
+
- [ ] Given a sound name that is not one of `none`, `done`, `question`, `permission` or `error`, When it arrives, Then it is treated as `none` and never becomes a path or a command, because the hop carries data only (see US-18).
|
|
236
|
+
|
|
225
237
|
---
|
|
226
238
|
|
|
227
239
|
## Epic E — Remote hosts over SSH
|
|
@@ -431,6 +443,7 @@ table was filled in.
|
|
|
431
443
|
| US-11 | D | `tests/stories/d-notifications.test.mjs`, `tests/notify-test.mjs` |
|
|
432
444
|
| US-12 | D | `tests/stories/d-notifications.test.mjs`, `tests/notify-test.mjs` |
|
|
433
445
|
| US-25 | D | `tests/stories/d-notifications.test.mjs` |
|
|
446
|
+
| US-29 | D | `tests/stories/d-notifications.test.mjs` |
|
|
434
447
|
| US-13 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/notify-test.mjs`, `tests/oc-test.mjs` |
|
|
435
448
|
| US-14 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
|
436
449
|
| US-18 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lawaty/lacode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"files": [
|
|
44
44
|
"dist",
|
|
45
45
|
"presets",
|
|
46
|
+
"sounds",
|
|
46
47
|
"LICENSE",
|
|
47
48
|
"README.md",
|
|
48
49
|
"docs"
|
package/sounds/done.wav
ADDED
|
Binary file
|
package/sounds/error.wav
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|