@lawaty/lacode 0.1.0 → 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/README.md CHANGED
@@ -39,6 +39,12 @@ directory to create. Do not run `npm i -g @lawaty/lacode`: a global npm install
39
39
  is never consulted by opencode, so it changes nothing about whether the plugin
40
40
  loads.
41
41
 
42
+ It is also the form to keep as your default: if your opencode does not load the
43
+ bare specifier, add to this line rather than replacing it. The
44
+ [procedure below](#if-the-agents-are-missing) says when that is, and
45
+ [which versions this is known on](#which-opencode-versions-this-is-known-on)
46
+ says what is actually verified.
47
+
42
48
  To install it explicitly instead — or to install it once for every project on
43
49
  the machine rather than per config — use the CLI, which does the same thing:
44
50
 
@@ -53,25 +59,53 @@ agent set will look stale for no visible reason.
53
59
 
54
60
  ### Where it is installed
55
61
 
56
- opencode installs npm plugins itself, into one directory per specifier under
57
- its cache — not into your project's `node_modules`, and not from anywhere a
58
- global npm install put them:
62
+ opencode installs npm plugins itself, into one directory per specifier under its
63
+ cache — not into your project's `node_modules`, and not from anywhere a global
64
+ npm install put them. Ask opencode where that cache is instead of assuming,
65
+ because where it lives differs by platform:
66
+
67
+ ```bash
68
+ opencode debug paths
69
+ ```
70
+
71
+ It prints the directories opencode is using on this machine — config, data, log
72
+ and the rest. Find the **cache** line and append
73
+ `/packages/<specifier>/node_modules/` to the path it reports. On Linux with no
74
+ `XDG_CACHE_HOME` set, that comes out as, for example:
59
75
 
60
76
  ```
61
- $XDG_CACHE_HOME/opencode/packages/<specifier>/node_modules/
62
- # ~/.cache/opencode/packages/@lawaty/lacode@latest/node_modules/ by default
77
+ ~/.cache/opencode/packages/@lawaty/lacode@latest/node_modules/
63
78
  ```
64
79
 
65
- `$XDG_CACHE_HOME` defaults to `~/.cache`; set it if you keep a cache elsewhere.
66
- To inspect what a specifier actually resolved to, list its directory under
67
- `~/.cache/opencode/packages/`. An empty directory with no `node_modules` inside
68
- it means the install failed — almost always an unpublished or misspelled package
69
- — and opencode starts normally **without** the plugin.
80
+ **That last line is an illustration of one platform's layout, not a path to
81
+ copy.** Where the same cache root sits on another operating system is not
82
+ something this document should guess at — read it from `opencode debug paths`
83
+ and use what it prints.
84
+
85
+ To inspect what a specifier actually resolved to, list its directory under that
86
+ cache root. An **empty** directory with no `node_modules` inside it means the
87
+ install failed — almost always an unpublished or misspelled package — and
88
+ opencode starts normally **without** the plugin. A **populated** directory there
89
+ means the install succeeded and tells you nothing at all about whether the
90
+ plugin was loaded; that is the next section, and telling those two apart is the
91
+ whole reason for reading it.
92
+
93
+ ### If the agents are missing
94
+
95
+ A plugin that fails to load is silent: opencode starts normally, prints nothing,
96
+ logs nothing, and the agents are simply not there. Work down this. Every step
97
+ below is written to hold whichever opencode version you are running — none of
98
+ them depends on knowing the answer in advance.
99
+
100
+ **1 — Write the bare specifier and restart.**
101
+
102
+ ```json
103
+ { "plugin": ["@lawaty/lacode"] }
104
+ ```
70
105
 
71
- ### Verifying the install
106
+ Nothing here hot-reloads, so the restart is part of the step, not a footnote.
72
107
 
73
- A plugin that fails to install is silent: there is no error, opencode starts,
74
- and it simply does not have the agents. Check for them:
108
+ **2 — Check whether the plugin loaded.**
75
109
 
76
110
  ```bash
77
111
  opencode agent list | grep -E '^(explore-fast|implement-fast|explore-deep|implement-deep)'
@@ -79,11 +113,123 @@ opencode agent list | grep -E '^(explore-fast|implement-fast|explore-deep|implem
79
113
 
80
114
  A working install prints `explore-fast`, `implement-fast`, `explore-deep` and
81
115
  `implement-deep`, each with the hidden `-1`..`-4` pool variants alongside. If the
82
- grep returns nothing but opencode's built-ins, the plugin did not load: check the
83
- cache directory above, and check that `plugin` is in the config file opencode
84
- actually reads. Run this in the same shell environment opencode runs in — an
116
+ grep returns nothing and the list holds only opencode's own built-ins, the plugin
117
+ did not load. Run it in the same shell environment opencode runs in — an
85
118
  `agent list` under a different `HOME` or `XDG_CONFIG_HOME` tests a different
86
- machine.
119
+ machine. **If the agents are listed, stop here: the one line above is the whole
120
+ install**, and nothing further in this section applies to you.
121
+
122
+ **3 — Decide which failure you are looking at.** Inspect the package directory
123
+ under the cache root from
124
+ [Where it is installed](#where-it-is-installed), and also check that `plugin`
125
+ really is in the config file opencode reads.
126
+
127
+ - **Empty** (no `node_modules` inside): the install failed — check the package
128
+ name, and that it is published. There is nothing to load yet.
129
+ - **Populated**: the install worked and the loader dropped the plugin. Go to
130
+ step 4. These two look identical from the outside, which is why this step
131
+ exists.
132
+
133
+ **4 — If the install worked and the agents are still missing, point the loader
134
+ at the built file.** Add a second entry; leave the bare name in place:
135
+
136
+ ```json
137
+ {
138
+ "plugin": [
139
+ "@lawaty/lacode",
140
+ "file:///ABSOLUTE-PATH-FROM-debug-paths/packages/@lawaty/lacode@latest/node_modules/@lawaty/lacode/dist/index.js"
141
+ ]
142
+ }
143
+ ```
144
+
145
+ `ABSOLUTE-PATH-FROM-debug-paths` is a placeholder to be replaced, not a
146
+ directory: take the cache path `opencode debug paths` reports for **your**
147
+ machine and put it in front of `/packages/…`. It must be an absolute path with
148
+ `$HOME` already expanded and no `~` left in it. Re-derive it after a cache wipe
149
+ or a reinstall — the directory is keyed by specifier, so it moves when the
150
+ version does.
151
+
152
+ Why keep both entries instead of swapping the bare name out:
153
+
154
+ - The bare name is what makes opencode install the package into its cache, and
155
+ therefore what creates the path the `file://` entry points at.
156
+ - The `file://` entry is what actually loads the plugin.
157
+ - They **cannot double-inject** on opencode 1.18.35, where the bare name
158
+ contributes nothing at all: the pair loads the plugin exactly once. On any
159
+ other version this is untested, which is precisely why step 4 is something you
160
+ take *because* step 2 showed the agents missing, and never on the expectation
161
+ that it would help.
162
+
163
+ **5 — Restart, and expect two restarts after a cache wipe.** The first start
164
+ installs the package and creates the path the `file://` entry points at, but a
165
+ path that does not exist at startup is a path that cannot be loaded in that same
166
+ run. The second start finds it and loads the plugin. After that it is steady
167
+ state.
168
+
169
+ This package also publishes `main` and an `exports["./server"]` entry pointing at
170
+ the same built file, because those are two of the shapes a plugin loader
171
+ resolving an entry point consults. They are forward-compatibility metadata and
172
+ they cost nothing — but they do **not** fix this on 1.18.35: adding either one to
173
+ the *installed* cache copy was tested there and the bare specifier still loaded
174
+ nothing. No other version has been observed where they help either. Use the
175
+ `file://` entry.
176
+
177
+ ### Which opencode versions this is known on
178
+
179
+ Exactly one version has been measured, and the procedure above is deliberately
180
+ not built around that fact. Treat this table as the evidence it is, which is one
181
+ row wide.
182
+
183
+ | opencode version | bare spec loads? | `file://` needed? | evidence |
184
+ | --- | --- | --- | --- |
185
+ | 1.18.35 | No — installs the package, then silently drops it | Yes | **Verified** on 1.18.35: a populated cache directory, zero agents injected, no error and no log line; the `file://` entry loads the full agent set. |
186
+ | Any other version | **Unknown** | **Unknown** | **Untested.** No other opencode version has been run against this package in either direction. |
187
+
188
+ Every command quoted on this page was run against 1.18.35. **If your version is
189
+ not the one measured here, follow the procedure above rather than reading this
190
+ table as coverage of your build**: each step stands on its own, and step 2 tells
191
+ you which situation you are in before any version-specific advice is relevant.
192
+
193
+ ### macOS
194
+
195
+ The plugin runs on macOS. It is plain JavaScript with no native dependency, and
196
+ what the package publishes is the built JavaScript rather than TypeScript
197
+ sources, so nothing about loading it is Linux-only. Use the procedure above
198
+ unchanged.
199
+
200
+ Three things differ, and all three are worth reading rather than assuming:
201
+
202
+ - **Paths.** Do not copy a cache path out of this document — the example under
203
+ [Where it is installed](#where-it-is-installed) is one platform's layout. Run
204
+ `opencode debug paths` and build the `file://` entry from what it reports.
205
+ This page deliberately does not name a macOS cache root: where opencode keeps
206
+ it on a Mac has not been established here, and a plausible-looking guess is
207
+ worse than an instruction to look.
208
+ - **Notifications** are delivered through `osascript`, or through
209
+ `terminal-notifier` when `osascript` is not installed, rather than through the
210
+ Linux `notify-send` (US-26). `lacode doctor` reports which channel your machine
211
+ would actually use.
212
+ - **The repository's shell scripts are Linux-only by design**, and are not in the
213
+ published package. They drive one person's SSH topology, so each is on the side
214
+ it belongs to:
215
+
216
+ | Script | Runs on | Why it is Linux-only |
217
+ | --- | --- | --- |
218
+ | `bin/oc`, `bin/oc-notify` | the **remote host** that runs opencode | Server-side. They are pushed to `~/bin/` on each host by `bin/oc-sync` and notify *your desktop* over the tunnel. |
219
+ | `bin/oc-notify-receiver` | your **desktop** | Delivers through `notify-send` (libnotify), and the persistent forward that reaches it is a systemd user unit. |
220
+ | `bin/oc-sync` | your **desktop** | Installs those systemd user units and runs `loginctl enable-linger`. |
221
+
222
+ None of this is a limitation of the package: a macOS client installs the plugin
223
+ and uses the `lacode` CLI, and both are portable. The scripts above are the
224
+ author's personal remote-tunnel tooling. `bin/oc-sync` refuses to run where it
225
+ cannot work and says which `lacode` subcommand to use instead, rather than
226
+ failing somewhere less obvious.
227
+
228
+ **The test suite runs on macOS.** It is written to pass on a Mac as well as on
229
+ Linux, so "it works on a Mac" is a supported claim rather than an intention. The
230
+ few checks that exercise the Linux-only shell scripts above skip themselves on a
231
+ machine that cannot run them, naming the reason — a bash 3.2 with no `mapfile`,
232
+ or no GNU `timeout` — rather than passing silently or failing confusingly.
87
233
 
88
234
  ### The `lacode` CLI
89
235
 
@@ -407,7 +553,8 @@ they call the same `scripts/build.mjs` the tarball does.
407
553
  is the guard: it reads the `### US-N:` headings out of `docs/USER-STORIES.md`
408
554
  and the `[US-N]` markers out of the test names and fails if they disagree, in
409
555
  either direction. **A test that covers a story must have the id at the start of
410
- its name** — `test("[US-7] a paid model is accepted")` — or CI's guard fails it.
556
+ its name** — `test("[US-7] a paid model is accepted")` — or the coverage guard
557
+ fails it.
411
558
  A story with no test is as much a failure as a test naming a story that does not
412
559
  exist.
413
560
 
package/dist/lacode.js CHANGED
@@ -81,15 +81,41 @@ var have = (command2) => {
81
81
  return false;
82
82
  }
83
83
  };
84
- function sendLocal(request) {
85
- if (!have("notify-send")) return { ok: false, detail: "notify-send is not installed", transport: "local" };
86
- const args = [
87
- safeTitle(request.title),
88
- safeMessage(request.message),
89
- `--urgency=${normalizeUrgency(request.urgency)}`,
90
- `--icon=${normalizeIcon(request.icon)}`
91
- ];
92
- const result = run("notify-send", args, 5e3);
84
+ var CHANNEL_BINARY = {
85
+ "notify-send": "notify-send",
86
+ osascript: "osascript",
87
+ "terminal-notifier": "terminal-notifier"
88
+ };
89
+ var DARWIN_SCRIPT = [
90
+ "on run argv",
91
+ " display notification (item 3 of argv) with title (item 2 of argv)",
92
+ "end run"
93
+ ].join("\n");
94
+ var DARWIN_ARGV_MARKER = "lacode";
95
+ function localChannel(platform = process.platform) {
96
+ if (platform === "darwin") {
97
+ if (have(CHANNEL_BINARY.osascript)) return "osascript";
98
+ if (have(CHANNEL_BINARY["terminal-notifier"])) return "terminal-notifier";
99
+ return "none";
100
+ }
101
+ return have(CHANNEL_BINARY["notify-send"]) ? "notify-send" : "none";
102
+ }
103
+ var NO_CHANNEL = {
104
+ linux: "notify-send is not installed",
105
+ darwin: "neither osascript nor terminal-notifier is installed"
106
+ };
107
+ function sendLocal(request, platform = process.platform) {
108
+ const channel = localChannel(platform);
109
+ if (channel === "none") {
110
+ return { ok: false, detail: NO_CHANNEL[platform] ?? `no local notification channel on ${platform}`, transport: "local" };
111
+ }
112
+ const title = safeTitle(request.title);
113
+ const body = safeMessage(request.message);
114
+ 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)] : (
115
+ // Byte-identical to the notify-send call this package has always made.
116
+ [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
117
+ );
118
+ const result = run(CHANNEL_BINARY[channel], args, 5e3);
93
119
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
94
120
  }
95
121
  function sendRemote(request, tunnel) {
@@ -109,9 +135,9 @@ function sendRemote(request, tunnel) {
109
135
  const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
110
136
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
111
137
  }
112
- function notify(request, env = process.env) {
138
+ function notify(request, env = process.env, platform = process.platform) {
113
139
  try {
114
- return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request);
140
+ return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
115
141
  } catch (error) {
116
142
  return { ok: false, detail: String(error), transport: "none" };
117
143
  }
@@ -127,13 +153,16 @@ function forward(host, argv2, options = {}) {
127
153
  stdout: result.stdout
128
154
  };
129
155
  }
130
- function describe(env = process.env) {
156
+ function describe(env = process.env, platform = process.platform) {
131
157
  const tunnel = resolveTunnel(env);
132
158
  return {
133
159
  running: isRemote(env) ? "remote (over ssh)" : "local",
134
160
  user: tunnel.user || "(unset)",
135
161
  port: tunnel.port,
136
162
  key: tunnel.key,
163
+ channel: localChannel(platform),
164
+ // Still exactly what it always meant -- is the notify-send binary present --
165
+ // rather than a second opinion about which channel runs.
137
166
  notifySend: have("notify-send")
138
167
  };
139
168
  }
@@ -212,6 +241,8 @@ switch (command) {
212
241
  if (info.running.startsWith("remote")) {
213
242
  if (info.user === "(unset)") console.log("\nOC_LOCAL_USER is unset: the hop cannot name the desktop user, and guessing with `id -un` is wrong on most remote accounts.");
214
243
  console.log("Set OC_LOCAL_USER, OC_TUNNEL_PORT and OC_NOTIFY_KEY, or point OC_NOTIFY_HELPER at a helper that knows them.");
244
+ } else if (info.channel === "none") {
245
+ console.log("\nNo local notification channel: `lacode notify` on this machine has nothing to deliver with, and every send will be reported as failed rather than shown.");
215
246
  }
216
247
  process.exit(0);
217
248
  break;
package/dist/tunnel.js CHANGED
@@ -76,15 +76,41 @@ var have = (command) => {
76
76
  return false;
77
77
  }
78
78
  };
79
- function sendLocal(request) {
80
- if (!have("notify-send")) return { ok: false, detail: "notify-send is not installed", transport: "local" };
81
- const args = [
82
- safeTitle(request.title),
83
- safeMessage(request.message),
84
- `--urgency=${normalizeUrgency(request.urgency)}`,
85
- `--icon=${normalizeIcon(request.icon)}`
86
- ];
87
- const result = run("notify-send", args, 5e3);
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);
88
114
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
89
115
  }
90
116
  function sendRemote(request, tunnel) {
@@ -104,9 +130,9 @@ function sendRemote(request, tunnel) {
104
130
  const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
105
131
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
106
132
  }
107
- function notify(request, env = process.env) {
133
+ function notify(request, env = process.env, platform = process.platform) {
108
134
  try {
109
- return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request);
135
+ return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
110
136
  } catch (error) {
111
137
  return { ok: false, detail: String(error), transport: "none" };
112
138
  }
@@ -122,13 +148,16 @@ function forward(host, argv, options = {}) {
122
148
  stdout: result.stdout
123
149
  };
124
150
  }
125
- function describe(env = process.env) {
151
+ function describe(env = process.env, platform = process.platform) {
126
152
  const tunnel = resolveTunnel(env);
127
153
  return {
128
154
  running: isRemote(env) ? "remote (over ssh)" : "local",
129
155
  user: tunnel.user || "(unset)",
130
156
  port: tunnel.port,
131
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.
132
161
  notifySend: have("notify-send")
133
162
  };
134
163
  }
@@ -139,6 +168,7 @@ export {
139
168
  encodePayload,
140
169
  forward,
141
170
  isRemote,
171
+ localChannel,
142
172
  normalizeIcon,
143
173
  normalizeUrgency,
144
174
  notify,
package/docs/TUNNEL.md CHANGED
@@ -46,7 +46,9 @@ spaces, quotes or `*` survives intact.
46
46
  ## Sending
47
47
 
48
48
  The CLI picks the transport by where it is running — `SSH_CLIENT` or `SSH_TTY`
49
- means remote, so it forwards; otherwise it calls `notify-send` directly.
49
+ means remote, so it forwards; otherwise it delivers locally through whichever
50
+ desktop notifier this platform has: `notify-send` everywhere else, `osascript` on
51
+ macOS, with `terminal-notifier` as the macOS fallback when `osascript` is missing.
50
52
 
51
53
  ```bash
52
54
  lacode notify "build finished" "lapode: 4 files changed" normal dialog-information
@@ -55,12 +57,17 @@ lacode forward web-01 -- systemctl --user status oc-tunnel
55
57
  lacode doctor
56
58
  ```
57
59
 
58
- `notify` is fire-and-forget by design. An unreachable desktop, a missing key, a
59
- missing `notify-send` — all of them print a warning on stderr and exit 0. A
60
+ `notify` is fire-and-forget by design. An unreachable desktop, a missing key, no
61
+ notifier installed at all — all of them print a warning on stderr and exit 0. A
60
62
  notification must never be able to fail the process that tried to send one; an
61
63
  agent must not go down because a laptop went to sleep. `forward` is the
62
64
  exception: it exits with the remote's status, because its caller wants to know.
63
65
 
66
+ On macOS the text is passed to `osascript` as arguments to an `on run argv`
67
+ handler, never spliced into the script source — a session title is not trusted
68
+ input. `lacode doctor` prints the channel that would actually run, so it is
69
+ useful on a Mac rather than reporting only whether a Linux binary is installed.
70
+
64
71
  `lacode doctor` prints what the environment resolves to and never gates on it.
65
72
 
66
73
  ## Configuration
@@ -80,10 +87,12 @@ nobody's desktop. Guessing the desktop user is worse than failing.
80
87
  ## Setting the tunnel up on the desktop
81
88
 
82
89
  The receiver runs on the machine with the desktop, as an `authorized_keys`
83
- forced command restricted to that one script:
90
+ forced command restricted to that one script. `$HOME` must be the expanded
91
+ absolute path — a `~` is not expanded there, and an unexpanded one produces an
92
+ `authorized_keys` entry that names a file that does not exist:
84
93
 
85
94
  ```
86
- restrict,command="/home/you/.local/bin/lacode-notify-receiver" ssh-ed25519 AAAA… oc-notify tunnel auth
95
+ restrict,command="/home/yourname/.local/bin/lacode-notify-receiver" ssh-ed25519 AAAA… oc-notify tunnel auth
87
96
  ```
88
97
 
89
98
  `restrict` is doing the security work: no shell, no PTY, no agent forwarding, no
@@ -96,12 +105,36 @@ The receiver must satisfy this contract:
96
105
  argument list — it is attacker-controlled, because it is whatever the remote
97
106
  user typed;
98
107
  3. split it on newlines into title / message / urgency / icon, each with a
99
- default;
108
+ default — **using only constructs bash 3.2 has**, not just bash 4+;
100
109
  4. whitelist `urgency` against `low|normal|critical` and `icon` against the six
101
110
  supported names, both of which become command-line flags;
102
111
  5. resolve `DISPLAY` / `DBUS_SESSION_BUS_ADDRESS` for the session it is running
103
112
  in, then `exec notify-send "$TITLE" "$MSG" --urgency=… --icon=…`.
104
113
 
114
+ Point 3 is not a detail. `mapfile`/`readarray` are bash 4.0+, and where they are
115
+ missing — macOS ships bash 3.2, as do some minimal Linux images — the array is
116
+ simply never built, every field falls back to its default, and the desktop gets
117
+ a notification titled "LaCode" with an empty body. No error is printed anywhere
118
+ that anyone will read, because the receiver's stderr goes to whoever happened to
119
+ open the ssh connection. A `while IFS= read -r` loop is equivalent and works
120
+ everywhere; `bin/oc-notify-receiver` uses one.
121
+
122
+ ### This half of the hop is Linux
123
+
124
+ The receiver delivers through `notify-send`, and the forward that reaches it is
125
+ a systemd `--user` unit (below). Both are Linux, so **the desktop half of this
126
+ tunnel does not exist on macOS**, and that is by design rather than a gap.
127
+
128
+ On a Mac you do not need it: `lacode notify` run locally picks `osascript` (or
129
+ `terminal-notifier`) and shows the notification itself, with no tunnel, no
130
+ `authorized_keys` entry and no systemd unit involved. The tunnel exists for the
131
+ case opencode runs *somewhere else*, and a Mac is not going to be running it.
132
+
133
+ If you point the receiver at a machine with no `notify-send` anyway, it exits
134
+ non-zero and says so, naming `lacode notify` as the path that does work there.
135
+ `bin/oc-sync` refuses the same machine for the same reason, and names each
136
+ missing prerequisite instead of failing somewhere less obvious.
137
+
105
138
  > **Note.** The published package ships the built CLI (`dist/lacode.js`, linked
106
139
  > as `lacode`) and the tunnel behind it (`dist/tunnel.js`, importable as
107
140
  > `@lawaty/lacode/tunnel`), not a receiver script. The receiver is the
@@ -246,6 +246,24 @@ does not.
246
246
  - [ ] Given a payload containing shell metacharacters (`$(…)`, backticks, `;`, `&&`) or extra fields, When the receiver handles it, Then it is parsed as data only: nothing is executed and the argument list passed to `notify-send` is unchanged.
247
247
  - [ ] Given a payload that cannot be understood at all, When it arrives, Then the receiver still exits 0 and pops up what it received, rather than failing the hop.
248
248
 
249
+ ### US-26: A local notification is delivered on macOS, not only on Linux
250
+ **As a** user whose desktop is a Mac, **I want** a notification to actually appear when a session finishes, **so that** installing the package on macOS is not the same as getting no notifications at all.
251
+
252
+ **Story ID(s) for tests:** US-26
253
+ **Acceptance criteria:**
254
+ - [ ] Given the notification is raised on macOS, When it is delivered locally, Then it goes to `osascript` — or to `terminal-notifier` if `osascript` is not installed — and not to `notify-send`, which does not exist there. Before this, the send exited 0, printed nothing, and popped up nothing.
255
+ - [ ] Given the channel is chosen, When it is picked, Then it is decided by `process.platform` and by which binary is actually installed, never by guessing, and every platform other than macOS keeps the `notify-send` call it has always made.
256
+ - [ ] Given the title and the body reach `osascript`, When the script runs, Then they are passed as arguments to an `on run argv` handler and never concatenated into the script source, because both fields are attacker-influenced text — a session title, a file path, a command.
257
+ - [ ] Given a body containing `"`, `'`, `;`, `$(…)` or backticks, When the macOS send runs, Then it reaches `display notification` byte-for-byte as data and nothing is executed.
258
+ - [ ] Given a machine has no notifier for its platform, When a notification is sent, Then the result is a failed delivery with a reason and no exception, and `lacode doctor` names the channel that would run — or reports that there is none — instead of reporting a binary that is not used there.
259
+ - [ ] Given the CLI runs on macOS, When the send does not complete, Then it is still bounded by a timeout, exactly as on Linux.
260
+ - [ ] Given the repository's own shell scripts, When each is classified, Then the doc says which side of the hop it runs on — `bin/oc` and `bin/oc-notify` on the remote host that runs opencode, `bin/oc-notify-receiver` and `bin/oc-sync` on the desktop — and says plainly why the desktop pair is Linux-only (a `notify-send` channel behind a systemd user unit), so a Mac user is not sent to debug an ssh script that was never meant to run on their machine.
261
+ - [ ] Given a machine without GNU `timeout` or with bash older than 4.0, When `bin/oc-sync` runs, Then it refuses **before** touching anything, names each missing prerequisite, and names the `lacode` subcommand to use instead. It must never report a misleading cause: the host list is read with `mapfile`, so without it the run used to die at "no hosts configured" — a confident wrong answer that sends the reader to edit `.env`.
262
+ - [ ] Given `bin/oc-notify-receiver` runs where `notify-send` does not exist, When it is invoked, Then it exits non-zero and names both the missing channel and the portable path, because its stderr reaches whoever opened the ssh connection and nobody else, and a bare "command not found" reads as a desktop that quietly stopped notifying.
263
+ - [ ] Given the payload is parsed, When the receiver splits `$SSH_ORIGINAL_COMMAND`, Then it splits on newlines only using constructs that exist in bash 3.2 as well as bash 5, and the suite **proves the split by running the receiver** rather than by finding the word `mapfile` in the file. Under bash 3.2 — the version macOS ships — a bash-4-only builtin left every field at its default, so the popup said "LaCode" with an empty body and no error anywhere: the same silent blank notification the argv bug caused once, reached again through a different door.
264
+ - [ ] Given the whole suite, When it runs on macOS, Then it passes there too. Checks that exercise the Linux-only shell scripts **skip** on a machine that cannot run them, each naming the specific missing prerequisite (a bash version below 4.0, an absent GNU `timeout`) rather than passing silently.
265
+ - [ ] Given the local notification channel differs by platform, When a test asserts on the notifier's argv, Then it reads **every** channel's stub rather than only `notify-send`. A Mac picks `osascript` and never invokes `notify-send` at all, so a suite that stubbed only `notify-send` would pass on Linux, read an empty log on a Mac, and in the process invoke the runner's real `osascript` against a desktop session that is not there.
266
+
249
267
  ---
250
268
 
251
269
  ## Epic F — Adoption and safety
@@ -258,6 +276,12 @@ does not.
258
276
  - [ ] Given a fresh machine with no LaCode config, When the install runs, Then one config line is sufficient and no further manual step is required.
259
277
  - [ ] Given no configuration is present at all, When LaCode boots, Then it falls back to shipped defaults and still functions.
260
278
  - [ ] Given the plugin package is installed, When its peer dependency is unmet, Then the requirement is declared rather than silently duplicated inside the package.
279
+ - [ ] Given the published manifest, When a plugin loader looks for an entry point, Then it finds one in both shapes a resolver may consult — a top-level `main` and `exports["./server"]` — while `exports["."]` keeps pointing at the same built plugin, and the documentation states plainly that these are forward-compatibility metadata and **not** a fix for opencode 1.18.35, where adding them to the installed copy was tested and the bare specifier still loaded nothing.
280
+ - [ ] Given an opencode that does not load the bare specifier, When the reader follows the install documentation, Then the README offers a numbered procedure that holds **whichever version they run** — write the bare specifier and restart, verify with `opencode agent list`, and only if the agents are still missing while the package is installed, add a `file://` entry and restart — rather than instructions scoped to one build. Each step is justified by what the previous step showed, not by what the reader's version is known to do.
281
+ - [ ] Given a version nobody has tested, When the reader checks the version table, Then **every** row other than the measured one is marked untested or unknown, the README claims no loading behaviour for any version it has not run, and it tells the reader to follow the procedure rather than to read the table as coverage of their build. The one row that does claim behaviour records the evidence behind it, and says that installing the package and then silently dropping it is what was measured.
282
+ - [ ] Given any operating system, When the reader needs the package directory to build a `file://` entry, Then the path comes from `opencode debug paths` rather than a hardcoded root, and any literal path in the document is marked as an illustration of one platform's layout instead of a value to copy. Where a platform's cache root has not been established, the README says so rather than guessing it.
283
+ - [ ] Given a macOS user, When they look for what works on their machine, Then the README documents macOS: the plugin runs there, notifications are delivered through `osascript` or `terminal-notifier`, paths still come from `opencode debug paths` with no asserted macOS cache root, and the remote-host and tunnel scripts are marked Linux-only rather than implied to run there.
284
+ - [ ] Given a user whose cache holds an installed package, When the plugin's agents are nevertheless absent, Then the README distinguishes that from a failed install (an empty cache directory) and points at the loader workaround, so "installed" and "loaded" are never reported as the same fact.
261
285
 
262
286
  ### US-16: Merges, never clobbers
263
287
  **As a** user with an existing opencode config, **I want** LaCode to add its agents/commands only where absent and never overwrite my own definitions, **so that** installing it cannot break my setup.
@@ -378,6 +402,7 @@ table was filled in.
378
402
  | US-13 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/notify-test.mjs`, `tests/oc-test.mjs` |
379
403
  | US-14 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
380
404
  | US-18 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
405
+ | US-26 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
381
406
  | US-15 | F | `tests/stories/f-adoption-safety.test.mjs` |
382
407
  | US-16 | F | `tests/stories/f-adoption-safety.test.mjs` |
383
408
  | US-17 | F | `tests/stories/f-adoption-safety.test.mjs` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",
@@ -27,8 +27,10 @@
27
27
  "engines": {
28
28
  "node": ">=22.18"
29
29
  },
30
- "exports": {
30
+ "main": "./dist/index.js",
31
+ "exports": {
31
32
  ".": "./dist/index.js",
33
+ "./server": "./dist/index.js",
32
34
  "./pool": "./dist/pool.js",
33
35
  "./agents": "./dist/agents.js",
34
36
  "./commands": "./dist/commands.js",