@lawaty/lacode 0.9.0 → 0.10.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/CHANGELOG.md CHANGED
@@ -8,6 +8,72 @@ Versions come from git release commits; the npm version is bumped in the same
8
8
  commit as the changelog entry it describes.
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.10.0] — a notification waits for you instead of timing out, and a permission can be answered from the popup
12
+
13
+ ### Added
14
+
15
+ - **A permission prompt can be answered from the notification itself.** The
16
+ plugin reads the permission request id off opencode's `permission.asked`
17
+ event and puts it in the helper's environment (`LACODE_REQUEST_ID`), so the
18
+ notification carries three buttons: **Allow once**, **Always allow** and
19
+ **Deny**. Their names are opencode's own reply values — `once`, `always` and
20
+ `reject` ("reject" is the server's word for deny) — so a click is already a
21
+ valid answer and nothing has to be translated on the way.
22
+
23
+ The answer travels back over the same path the notification came. The desktop
24
+ end is an ssh forced command, so its stdout comes back down the ssh
25
+ connection and the plugin reads the chosen button's name off the helper's
26
+ stdout — no new key, no listener, no HTTP server. The hop was one-way until
27
+ now, and what made it two-way was a channel that was already there: the
28
+ forced command's stdout is carried to whoever opened the connection, so the
29
+ answer rides home on the connection that is already open. The plugin then
30
+ answers the permission through opencode's own API, and a permission that was
31
+ already answered in the terminal is recognised rather than reported as a
32
+ failure.
33
+
34
+ The wire payload is six newline-delimited fields on both wires —
35
+ `title\nmessage\nurgency\nicon\nsound\nrequestID`, the request id last — and a
36
+ payload with fewer than six fields carries no request id, so an older sender
37
+ still works.
38
+
39
+ Two limits, stated plainly. The scope is **permissions only**: a question is
40
+ not answerable this way, because opencode's v1 SDK client — the one this
41
+ plugin has — has no question API at all, and the v2 routes need a bound TCP
42
+ socket that the default opencode TUI does not have. And the buttons are
43
+ **Linux only**: `osascript` and `terminal-notifier` have no action support,
44
+ so a macOS desktop still gets a plain notice, and the answer there still has
45
+ to be given in the terminal.
46
+
47
+ ### Changed
48
+
49
+ - **A desktop notification now waits for you instead of timing out on its own.**
50
+ `notify-send` is called with `--expire-time=0` — the freedesktop "never
51
+ expire" timeout — at all three call sites: `sendLocal` in
52
+ `src/tunnel/index.ts`, the tunnel's forced command
53
+ (`bin/oc-notify-receiver`), and `send_notify` in `bin/oc`. Without it the
54
+ desktop daemon's own timer dismissed the popup by itself: measured on KDE
55
+ Plasma 6.7.4, a normal-urgency notification is closed with reason 1
56
+ ("expired") after about five seconds, so a notice the user had not acted on
57
+ within that window was simply gone — the difference between a notice you can
58
+ still act on and one you missed. A notification now stays on the desktop
59
+ until you close it.
60
+
61
+ Nothing else about it changed: the `--urgency` values are untouched (a
62
+ question or a permission prompt is deliberately not `critical`, so it does
63
+ not escalate to a modal on most desktops), and the whitelists and the
64
+ cooldown behaviour are exactly as they were.
65
+
66
+ ### Documented
67
+
68
+ - **The npm publish notice does not say which dist-tag a release lands on.** npm
69
+ 12 prints "Publishing to https://registry.npmjs.org/ with tag latest" for every
70
+ publish, including one whose `publishConfig.tag` sends it to `beta`: the
71
+ message reports the CLI's default, not the resolved tag. 0.9.0 was published
72
+ that way and landed on `beta`, with `latest` still at 0.4.2. The registry is
73
+ the authority, not the notice — after publishing, read
74
+ `npm view @lawaty/lacode dist-tags` before assuming anything about where the
75
+ release went.
76
+
11
77
  ## [0.9.0] — the cartographer keeps an active backlog, and the model stops naming slot variants
12
78
 
13
79
  ### Added
@@ -464,7 +530,8 @@ experimental; `latest` remains 0.4.2.
464
530
 
465
531
  - First published release: pooled subagents over a shared set of models.
466
532
 
467
- [Unreleased]: https://github.com/lawaty/lacode/compare/v0.9.0...HEAD
533
+ [Unreleased]: https://github.com/lawaty/lacode/compare/v0.10.0...HEAD
534
+ [0.10.0]: https://github.com/lawaty/lacode/compare/v0.9.0...v0.10.0
468
535
  [0.9.0]: https://github.com/lawaty/lacode/compare/v0.8.0...v0.9.0
469
536
  [0.8.0]: https://github.com/lawaty/lacode/compare/v0.7.1...v0.8.0
470
537
  [0.7.1]: https://github.com/lawaty/lacode/compare/v0.7.0...v0.7.1
package/README.md CHANGED
@@ -346,9 +346,11 @@ lacode doctor # what the environment reso
346
346
  ```
347
347
 
348
348
  `lacode notify` is fire-and-forget and always exits 0 — an unreachable desktop
349
- must never be able to fail the process that tried to notify you. `lacode forward
350
- HOST -- COMMAND` runs a command on another host over ssh and exits with its
351
- status.
349
+ must never be able to fail the process that tried to notify you. The one
350
+ exception is an actionable notification — one carrying a permission request id
351
+ — which waits for the click, because the answer is the whole point of it.
352
+ `lacode forward HOST -- COMMAND` runs a command on another host over ssh and
353
+ exits with its status.
352
354
 
353
355
  The full CLI is `notify`, `forward`, `doctor`, `version`, `update`, `receive`
354
356
  and `tunnel setup`. `version` reports which copy of LaCode is running — and
package/dist/index.js CHANGED
@@ -2832,6 +2832,8 @@ var helperArgv = (helper, title, message, urgency, icon, sound) => {
2832
2832
  const name = helper.split(/[\\/]/).pop() ?? "";
2833
2833
  return name === "lacode" || name === "lacode.js" ? ["notify", title, message, urgency, icon, sound] : [title, message, urgency, icon, sound];
2834
2834
  };
2835
+ var PERMISSION_REPLIES = ["once", "always", "reject"];
2836
+ var REQUEST_ID_ENV = "LACODE_REQUEST_ID";
2835
2837
  var safeStringify = (value) => {
2836
2838
  try {
2837
2839
  return JSON.stringify(value) ?? void 0;
@@ -2901,12 +2903,16 @@ var shouldNotify = (input) => {
2901
2903
  if (outcome === "done" && input.awaitingResponse) {
2902
2904
  return { notify: false, reason: "awaiting your response" };
2903
2905
  }
2904
- return {
2906
+ const notification = {
2905
2907
  notify: true,
2906
2908
  title: `opencode \u2014 ${title}`,
2907
2909
  message: notifyMessage(outcome, detail),
2908
2910
  urgency: outcome === "error" ? "critical" : "normal"
2909
2911
  };
2912
+ if (outcome === "permission" && input.requestID) {
2913
+ return { ...notification, actionable: { requestID: input.requestID } };
2914
+ }
2915
+ return notification;
2910
2916
  };
2911
2917
  var questionHeadline = (questions) => {
2912
2918
  const first = questions?.find((entry) => typeof entry?.question === "string" && entry.question.trim());
@@ -2987,6 +2993,7 @@ var SERVICE3 = "notify";
2987
2993
  var COOLDOWN_MS2 = 5e3;
2988
2994
  var MAX_INFLIGHT_HELPERS = 4;
2989
2995
  var HELPER_TIMEOUT_MS = 3e4;
2996
+ var ACTION_TIMEOUT_MS = 10 * 6e4 + 3e4;
2990
2997
  var MAX_TRACKED_SESSIONS = 500;
2991
2998
  var SOUND_ENV = "LACODE_SOUND";
2992
2999
  var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
@@ -3026,11 +3033,50 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3026
3033
  };
3027
3034
  const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
3028
3035
  });
3036
+ const answerPermission = async (sessionID, requestID, action) => {
3037
+ const response = PERMISSION_REPLIES.find((candidate) => candidate === action);
3038
+ if (!response) {
3039
+ void log("warn", `notification action ignored: ${JSON.stringify(action)} is not a permission reply`, {
3040
+ sessionID,
3041
+ requestID
3042
+ });
3043
+ return;
3044
+ }
3045
+ const errorStatus = (error) => {
3046
+ const candidate = error;
3047
+ const status2 = candidate?.response?.status ?? candidate?.cause?.status ?? candidate?.status;
3048
+ return typeof status2 === "number" ? status2 : void 0;
3049
+ };
3050
+ const reply = await client.postSessionIdPermissionsPermissionId({
3051
+ path: { id: sessionID, permissionID: requestID },
3052
+ body: { response }
3053
+ }).then((result) => ({ result })).catch((error) => ({ error }));
3054
+ const status = "result" in reply ? reply.result.response?.status : errorStatus(reply.error);
3055
+ if (status === 404) {
3056
+ void log("debug", "permission already answered", { sessionID, requestID });
3057
+ return;
3058
+ }
3059
+ if ("error" in reply) {
3060
+ void log("warn", `permission reply failed: ${errorDetail(reply.error) ?? String(reply.error)}`, {
3061
+ sessionID,
3062
+ requestID
3063
+ });
3064
+ return;
3065
+ }
3066
+ if (reply.result.error) {
3067
+ void log("warn", `permission reply failed: ${errorDetail(reply.result.error) ?? String(reply.result.error)}`, {
3068
+ sessionID,
3069
+ requestID
3070
+ });
3071
+ return;
3072
+ }
3073
+ void log("info", "permission answered from the notification", { sessionID, requestID, response });
3074
+ };
3029
3075
  const fetchInfo = async (sessionID) => {
3030
3076
  const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
3031
3077
  return info;
3032
3078
  };
3033
- const deliverViaHelper = (title, message, urgency, sound) => new Promise((resolve3) => {
3079
+ const deliverViaHelper = (title, message, urgency, sound, requestID) => new Promise((resolve3) => {
3034
3080
  if (inflight >= maxInflight) {
3035
3081
  void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
3036
3082
  resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
@@ -3045,7 +3091,13 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3045
3091
  resolve3({ ok: false, detail: "no oc-notify helper found" });
3046
3092
  return;
3047
3093
  }
3048
- const child = spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
3094
+ const actionable = Boolean(requestID);
3095
+ const timeoutMs = actionable ? ACTION_TIMEOUT_MS : helperTimeoutMs;
3096
+ const child = actionable ? spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
3097
+ stdio: ["ignore", "pipe", "pipe"],
3098
+ detached: false,
3099
+ env: { ...process.env, [REQUEST_ID_ENV]: requestID }
3100
+ }) : spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
3049
3101
  stdio: ["ignore", "ignore", "pipe"],
3050
3102
  detached: true
3051
3103
  });
@@ -3064,11 +3116,15 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3064
3116
  child.kill("SIGKILL");
3065
3117
  } catch {
3066
3118
  }
3067
- void log("warn", `notification helper timed out after ${helperTimeoutMs}ms`, { title });
3068
- finish({ ok: false, detail: `helper timed out after ${helperTimeoutMs}ms` });
3069
- }, helperTimeoutMs);
3119
+ void log("warn", `notification helper timed out after ${timeoutMs}ms`, { title });
3120
+ finish({ ok: false, detail: `helper timed out after ${timeoutMs}ms` });
3121
+ }, timeoutMs);
3070
3122
  timer.unref?.();
3123
+ let stdout = "";
3071
3124
  let stderr = "";
3125
+ child.stdout?.on("data", (chunk) => {
3126
+ if (stdout.length < 400) stdout += String(chunk);
3127
+ });
3072
3128
  child.stderr?.on("data", (chunk) => {
3073
3129
  if (stderr.length < 400) stderr += String(chunk);
3074
3130
  });
@@ -3085,12 +3141,13 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3085
3141
  message
3086
3142
  });
3087
3143
  }
3088
- finish({ ok, detail: noise || void 0 });
3144
+ const action = actionable ? stdout.trim() : "";
3145
+ finish({ ok, detail: noise || void 0, ...action ? { action } : {} });
3089
3146
  });
3090
- child.unref();
3147
+ if (!actionable) child.unref();
3091
3148
  });
3092
3149
  const deliver = deps.deliver ?? deliverViaHelper;
3093
- const announce = async (sessionID, outcome, detail) => {
3150
+ const announce = async (sessionID, outcome, detail, requestID) => {
3094
3151
  try {
3095
3152
  const info = await fetchInfo(sessionID);
3096
3153
  if (!info) {
@@ -3106,7 +3163,8 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3106
3163
  sawMessage: active.has(sessionID),
3107
3164
  // Either kind of block suppresses the idle event's "finished": a session
3108
3165
  // parked on a permission prompt has not finished, it is waiting.
3109
- awaitingResponse: pending.has(sessionID) || permissions.has(sessionID)
3166
+ awaitingResponse: pending.has(sessionID) || permissions.has(sessionID),
3167
+ requestID
3110
3168
  });
3111
3169
  if (!decision.notify) {
3112
3170
  void log("debug", `suppressed: ${decision.reason}`, { sessionID });
@@ -3124,8 +3182,10 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3124
3182
  }
3125
3183
  const message = isRemote ? `${decision.message} (remote)` : decision.message;
3126
3184
  const sound = soundOn && NOTIFY_OUTCOMES.includes(outcome) ? outcome : "none";
3127
- const sent = await deliver(decision.title, message, decision.urgency, sound);
3128
- void log(sent.ok ? "info" : "error", sent.ok ? "notified" : "notification failed", {
3185
+ const sent = await deliver(decision.title, message, decision.urgency, sound, requestID);
3186
+ if (sent.action && requestID) await answerPermission(sessionID, requestID, sent.action);
3187
+ const answered = Boolean(sent.action);
3188
+ void log(answered || sent.ok ? "info" : "error", answered ? "permission answered from the notification" : sent.ok ? "notified" : "notification failed", {
3129
3189
  sessionID,
3130
3190
  outcome,
3131
3191
  title: decision.title,
@@ -3169,7 +3229,8 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3169
3229
  if (!sessionID2) return;
3170
3230
  permissions.add(sessionID2);
3171
3231
  rememberActive(sessionID2);
3172
- void announce(sessionID2, "permission", permissionHeadline(properties) || void 0);
3232
+ const requestID = typeof properties?.id === "string" && properties.id ? properties.id : void 0;
3233
+ void announce(sessionID2, "permission", permissionHeadline(properties) || void 0, requestID);
3173
3234
  return;
3174
3235
  }
3175
3236
  if (event.type === "permission.replied") {
package/dist/lacode.js CHANGED
@@ -76,15 +76,21 @@ var safeText = (raw, fallback, max) => {
76
76
  };
77
77
  var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
78
78
  var safeMessage = (raw) => safeText(raw ?? "", "", 400);
79
+ var safeRequestId = (raw) => {
80
+ const clean = (raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
81
+ if (!clean) return void 0;
82
+ return clean.length > 128 ? clean.slice(0, 128) : clean;
83
+ };
79
84
  var FIELD_SEPARATOR = "\n";
80
85
  function parsePayload(raw) {
81
- const [title, message, urgency, icon, sound] = raw.split(FIELD_SEPARATOR);
86
+ const [title, message, urgency, icon, sound, requestID] = raw.split(FIELD_SEPARATOR);
82
87
  return {
83
88
  title: safeTitle(title),
84
89
  message: safeMessage(message),
85
90
  urgency: normalizeUrgency(urgency),
86
91
  icon: normalizeIcon(icon),
87
- sound: normalizeSound(sound)
92
+ sound: normalizeSound(sound),
93
+ requestID: safeRequestId(requestID)
88
94
  };
89
95
  }
90
96
  var encodePayload = (request) => [
@@ -92,7 +98,10 @@ var encodePayload = (request) => [
92
98
  safeMessage(request.message),
93
99
  normalizeUrgency(request.urgency),
94
100
  normalizeIcon(request.icon),
95
- normalizeSound(request.sound)
101
+ normalizeSound(request.sound),
102
+ // The sixth field, empty when there is no request id: an absent id and an
103
+ // empty one mean the same thing, and the field count never changes.
104
+ safeRequestId(request.requestID) ?? ""
96
105
  ].join(FIELD_SEPARATOR);
97
106
  var SSH_OPTS = [
98
107
  "-o",
@@ -175,6 +184,13 @@ var NO_CHANNEL = {
175
184
  linux: "notify-send is not installed",
176
185
  darwin: "neither osascript nor terminal-notifier is installed"
177
186
  };
187
+ var NEVER_EXPIRE_FLAG = "--expire-time=0";
188
+ var NOTIFICATION_ACTIONS = [
189
+ ["once", "Allow once"],
190
+ ["always", "Always allow"],
191
+ ["reject", "Deny"]
192
+ ];
193
+ var ACTION_WAIT_MS = 10 * 6e4;
178
194
  function sendLocal(request, platform = process.platform) {
179
195
  const sound = normalizeSound(request.sound);
180
196
  const playSound = () => {
@@ -195,35 +211,66 @@ function sendLocal(request, platform = process.platform) {
195
211
  }
196
212
  const title = safeTitle(request.title);
197
213
  const body = safeMessage(request.message);
214
+ const requestID = safeRequestId(request.requestID);
215
+ const actionable = channel === "notify-send" && requestID !== void 0;
216
+ const actionArgs = actionable ? NOTIFICATION_ACTIONS.map(([name, label]) => `--action=${name}=${label}`) : [];
198
217
  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)] : (
199
- // Byte-identical to the notify-send call this package has always made.
200
- [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
218
+ // Never auto-dismissed: the notification waits for the user to close it.
219
+ [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`, NEVER_EXPIRE_FLAG, ...actionArgs]
201
220
  );
202
- const result = run(CHANNEL_BINARY[channel], args, 5e3);
221
+ const result = run(CHANNEL_BINARY[channel], args, actionable ? ACTION_WAIT_MS : 5e3);
203
222
  playSound();
204
- return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
223
+ const action = actionable ? result.stdout.trim() : "";
224
+ return {
225
+ ok: result.ok,
226
+ detail: result.ok ? void 0 : result.stderr || result.detail,
227
+ transport: "local",
228
+ ...action ? { action } : {}
229
+ };
205
230
  }
206
231
  function sendRemote(request, tunnel) {
207
232
  if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
208
233
  const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
234
+ const actionable = safeRequestId(request.requestID) !== void 0;
235
+ const timeoutMs = actionable ? Math.max(tunnel.connectTimeoutMs + 2e3, ACTION_WAIT_MS + 2e3) : tunnel.connectTimeoutMs + 2e3;
236
+ const deliver = (args) => {
237
+ const result = run("ssh", args, timeoutMs);
238
+ const action = actionable ? result.stdout.trim() : "";
239
+ return {
240
+ ok: result.ok,
241
+ detail: result.ok ? void 0 : result.stderr || result.detail,
242
+ transport: "remote",
243
+ ...action ? { action } : {}
244
+ };
245
+ };
209
246
  if (tunnel.host) {
210
- const result2 = run(
211
- "ssh",
212
- [...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
213
- tunnel.connectTimeoutMs + 2e3
214
- );
215
- return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
216
- }
217
- const result = run(
218
- "ssh",
219
- [...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
220
- tunnel.connectTimeoutMs + 2e3
221
- );
222
- return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
247
+ return deliver([
248
+ ...SSH_OPTS,
249
+ ...CONTROL_OPTS,
250
+ ...connect,
251
+ "-i",
252
+ tunnel.key,
253
+ `${tunnel.user}@${tunnel.host}`,
254
+ "--",
255
+ encodePayload(request)
256
+ ]);
257
+ }
258
+ return deliver([
259
+ ...SSH_OPTS,
260
+ ...connect,
261
+ "-i",
262
+ tunnel.key,
263
+ "-p",
264
+ String(tunnel.port),
265
+ `${tunnel.user}@localhost`,
266
+ "--",
267
+ encodePayload(request)
268
+ ]);
223
269
  }
224
270
  function notify(request, env = process.env, platform = process.platform) {
225
271
  try {
226
- return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
272
+ const result = isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
273
+ return result.action ? { ...result, action: result.action } : result;
227
274
  } catch (error) {
228
275
  return { ok: false, detail: String(error), transport: "none" };
229
276
  }
@@ -639,6 +686,8 @@ var USAGE = `lacode \u2014 portable notifications and remote exec for LaCode
639
686
  permission | error; an omitted or unrecognised SOUND is none, so a
640
687
  four-argument caller simply gets no sound. Fire-and-forget: always
641
688
  exits 0.
689
+ With LACODE_REQUEST_ID set, the notification carries buttons and the
690
+ name of the clicked one is printed on stdout.
642
691
 
643
692
  lacode forward HOST -- COMMAND [ARG...]
644
693
  Run COMMAND on HOST over ssh. Exits with the remote's status.
@@ -666,7 +715,9 @@ Environment:
666
715
  OC_LOCAL_USER desktop user the tunnel authenticates as
667
716
  OC_TUNNEL_PORT reverse-forward listener port (default 10022)
668
717
  OC_NOTIFY_KEY dedicated key (default ~/.ssh/lacode_notify)
669
- OC_CONNECT_TIMEOUT_MS / OC_SSH_TIMEOUT_MS`;
718
+ OC_CONNECT_TIMEOUT_MS / OC_SSH_TIMEOUT_MS
719
+ LACODE_REQUEST_ID makes a notification actionable: it carries buttons, and
720
+ the clicked one is printed on stdout`;
670
721
  var argv = process.argv.slice(2);
671
722
  var command = argv.shift();
672
723
  switch (command) {
@@ -684,8 +735,16 @@ switch (command) {
684
735
  console.error(USAGE);
685
736
  process.exit(2);
686
737
  }
687
- const result = notify({ title: rawTitle, message, urgency, icon, sound: normalizeSound(sound) });
738
+ const result = notify({
739
+ title: rawTitle,
740
+ message,
741
+ urgency,
742
+ icon,
743
+ sound: normalizeSound(sound),
744
+ requestID: process.env.LACODE_REQUEST_ID
745
+ });
688
746
  if (!result.ok) console.error(`lacode: notification not delivered via ${result.transport}: ${result.detail ?? "unknown reason"}`);
747
+ if (result.action) console.log(result.action);
689
748
  process.exit(0);
690
749
  break;
691
750
  }
@@ -765,6 +824,7 @@ ${report.note}`);
765
824
  const request = parsePayload(process.env.SSH_ORIGINAL_COMMAND ?? "");
766
825
  const result = sendLocal(request);
767
826
  if (!result.ok) console.error(`lacode receive: ${result.detail ?? "not delivered"}`);
827
+ if (result.action) console.log(result.action);
768
828
  process.exit(result.ok ? 0 : 1);
769
829
  break;
770
830
  }
package/dist/tunnel.js CHANGED
@@ -69,15 +69,21 @@ var safeText = (raw, fallback, max) => {
69
69
  };
70
70
  var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
71
71
  var safeMessage = (raw) => safeText(raw ?? "", "", 400);
72
+ var safeRequestId = (raw) => {
73
+ const clean = (raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
74
+ if (!clean) return void 0;
75
+ return clean.length > 128 ? clean.slice(0, 128) : clean;
76
+ };
72
77
  var FIELD_SEPARATOR = "\n";
73
78
  function parsePayload(raw) {
74
- const [title, message, urgency, icon, sound] = raw.split(FIELD_SEPARATOR);
79
+ const [title, message, urgency, icon, sound, requestID] = raw.split(FIELD_SEPARATOR);
75
80
  return {
76
81
  title: safeTitle(title),
77
82
  message: safeMessage(message),
78
83
  urgency: normalizeUrgency(urgency),
79
84
  icon: normalizeIcon(icon),
80
- sound: normalizeSound(sound)
85
+ sound: normalizeSound(sound),
86
+ requestID: safeRequestId(requestID)
81
87
  };
82
88
  }
83
89
  var encodePayload = (request) => [
@@ -85,7 +91,10 @@ var encodePayload = (request) => [
85
91
  safeMessage(request.message),
86
92
  normalizeUrgency(request.urgency),
87
93
  normalizeIcon(request.icon),
88
- normalizeSound(request.sound)
94
+ normalizeSound(request.sound),
95
+ // The sixth field, empty when there is no request id: an absent id and an
96
+ // empty one mean the same thing, and the field count never changes.
97
+ safeRequestId(request.requestID) ?? ""
89
98
  ].join(FIELD_SEPARATOR);
90
99
  var SSH_OPTS = [
91
100
  "-o",
@@ -168,6 +177,13 @@ var NO_CHANNEL = {
168
177
  linux: "notify-send is not installed",
169
178
  darwin: "neither osascript nor terminal-notifier is installed"
170
179
  };
180
+ var NEVER_EXPIRE_FLAG = "--expire-time=0";
181
+ var NOTIFICATION_ACTIONS = [
182
+ ["once", "Allow once"],
183
+ ["always", "Always allow"],
184
+ ["reject", "Deny"]
185
+ ];
186
+ var ACTION_WAIT_MS = 10 * 6e4;
171
187
  function sendLocal(request, platform = process.platform) {
172
188
  const sound = normalizeSound(request.sound);
173
189
  const playSound = () => {
@@ -188,35 +204,66 @@ function sendLocal(request, platform = process.platform) {
188
204
  }
189
205
  const title = safeTitle(request.title);
190
206
  const body = safeMessage(request.message);
207
+ const requestID = safeRequestId(request.requestID);
208
+ const actionable = channel === "notify-send" && requestID !== void 0;
209
+ const actionArgs = actionable ? NOTIFICATION_ACTIONS.map(([name, label]) => `--action=${name}=${label}`) : [];
191
210
  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)] : (
192
- // Byte-identical to the notify-send call this package has always made.
193
- [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
211
+ // Never auto-dismissed: the notification waits for the user to close it.
212
+ [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`, NEVER_EXPIRE_FLAG, ...actionArgs]
194
213
  );
195
- const result = run(CHANNEL_BINARY[channel], args, 5e3);
214
+ const result = run(CHANNEL_BINARY[channel], args, actionable ? ACTION_WAIT_MS : 5e3);
196
215
  playSound();
197
- return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
216
+ const action = actionable ? result.stdout.trim() : "";
217
+ return {
218
+ ok: result.ok,
219
+ detail: result.ok ? void 0 : result.stderr || result.detail,
220
+ transport: "local",
221
+ ...action ? { action } : {}
222
+ };
198
223
  }
199
224
  function sendRemote(request, tunnel) {
200
225
  if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
201
226
  const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
227
+ const actionable = safeRequestId(request.requestID) !== void 0;
228
+ const timeoutMs = actionable ? Math.max(tunnel.connectTimeoutMs + 2e3, ACTION_WAIT_MS + 2e3) : tunnel.connectTimeoutMs + 2e3;
229
+ const deliver = (args) => {
230
+ const result = run("ssh", args, timeoutMs);
231
+ const action = actionable ? result.stdout.trim() : "";
232
+ return {
233
+ ok: result.ok,
234
+ detail: result.ok ? void 0 : result.stderr || result.detail,
235
+ transport: "remote",
236
+ ...action ? { action } : {}
237
+ };
238
+ };
202
239
  if (tunnel.host) {
203
- const result2 = run(
204
- "ssh",
205
- [...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
206
- tunnel.connectTimeoutMs + 2e3
207
- );
208
- return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
240
+ return deliver([
241
+ ...SSH_OPTS,
242
+ ...CONTROL_OPTS,
243
+ ...connect,
244
+ "-i",
245
+ tunnel.key,
246
+ `${tunnel.user}@${tunnel.host}`,
247
+ "--",
248
+ encodePayload(request)
249
+ ]);
209
250
  }
210
- const result = run(
211
- "ssh",
212
- [...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
213
- tunnel.connectTimeoutMs + 2e3
214
- );
215
- return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
251
+ return deliver([
252
+ ...SSH_OPTS,
253
+ ...connect,
254
+ "-i",
255
+ tunnel.key,
256
+ "-p",
257
+ String(tunnel.port),
258
+ `${tunnel.user}@localhost`,
259
+ "--",
260
+ encodePayload(request)
261
+ ]);
216
262
  }
217
263
  function notify(request, env = process.env, platform = process.platform) {
218
264
  try {
219
- return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
265
+ const result = isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
266
+ return result.action ? { ...result, action: result.action } : result;
220
267
  } catch (error) {
221
268
  return { ok: false, detail: String(error), transport: "none" };
222
269
  }
@@ -249,6 +296,7 @@ function describe(env = process.env, platform = process.platform) {
249
296
  export {
250
297
  FIELD_SEPARATOR,
251
298
  ICONS,
299
+ NOTIFICATION_ACTIONS,
252
300
  SOUNDS,
253
301
  describe,
254
302
  encodePayload,
@@ -262,6 +310,7 @@ export {
262
310
  parsePayload,
263
311
  resolveTunnel,
264
312
  safeMessage,
313
+ safeRequestId,
265
314
  safeTitle,
266
315
  sendLocal,
267
316
  sendRemote,
package/docs/TUNNEL.md CHANGED
@@ -15,8 +15,12 @@ gets it back.
15
15
  │ ▼ │ ONE argument: │ forced command) │
16
16
  │ lacode notify │ title\nmessage\n │ │ │
17
17
  │ │ urgency\nicon\nsound │ ▼ │
18
- │ │ │ notify-send / │
18
+ │ │ \nrequestID │ notify-send / │
19
19
  │ │ │ osascript │
20
+ │ ▲ │ ◄─────────────────────── │ │
21
+ │ │ the clicked button's │ │
22
+ │ │ name, on the forced │ │
23
+ │ │ command's stdout │ │
20
24
  └──────────────────┘ └────────────────────────┘
21
25
  ```
22
26
 
@@ -27,27 +31,67 @@ listening port, no systemd unit and nothing to sync: the plugin owns the
27
31
  transport, and it exists only while opencode runs — which is the only time a
28
32
  notification matters.
29
33
 
30
- **One argument, not five.** ssh concatenates everything after the host into a
34
+ **One argument, not six.** ssh concatenates everything after the host into a
31
35
  single command string, and a forced command starts with *no* positional
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
- as one newline-delimited argument:
36
+ parameters. A sender that passes six quoted arguments arrives as the single
37
+ string `"a b c d e f"`; a receiver reading `$1..$6` sees six empty strings,
38
+ falls back to six defaults, and pops up a blank notification. So the payload
39
+ crosses as one newline-delimited argument:
36
40
 
37
41
  ```
38
- title\nmessage\nurgency\nicon\nsound
42
+ title\nmessage\nurgency\nicon\nsound\nrequestID
39
43
  ```
40
44
 
41
- Five fields — `title`, `message`, `urgency`, `icon`, `sound`. `sound` is `none`,
42
- `done`, `question`, `permission` or `error`. A sender that stops at `icon`
43
- simply leaves it empty, and an empty or unrecognised name is `none`, so a
44
- four-field payload is a valid one.
45
+ Six fields — `title`, `message`, `urgency`, `icon`, `sound`, `requestID`.
46
+ `sound` is `none`, `done`, `question`, `permission` or `error`. The request id
47
+ is **last**, and it is opaque: it is never shown to you, never passed to a
48
+ command, and never used to build an argument — the only thing it decides is
49
+ whether the notification carries buttons. A sender that stops at `icon`
50
+ simply leaves the rest empty, and an empty or unrecognised name is `none`, so a
51
+ four-field payload is a valid one; a payload with fewer than six fields carries
52
+ no request id at all, and therefore no buttons, so an older sender still works
53
+ against a receiver that expects six.
45
54
 
46
55
  Newline is safe as the delimiter because both ends strip control characters from
47
56
  the free-text fields before assembling or reading it, and `urgency`, `icon` and
48
57
  `sound` come from whitelists. No field can contain the delimiter, so a message
49
58
  full of spaces, quotes or `*` survives intact.
50
59
 
60
+ ## The way back
61
+
62
+ The hop was one-way until now: the notification went up to the desktop and
63
+ nothing came back, because there was nothing on the wire to carry an answer.
64
+ It is two-way now, and what made it two-way was not a new mechanism. **The
65
+ desktop end is an ssh forced command, so its stdout comes back down the same
66
+ ssh connection the notification went up.** That channel was already there and
67
+ nobody used it: a forced command's stdout is carried to whoever opened the
68
+ connection, so the answer rides home on the connection that is already open —
69
+ no second connection, no listener, no port, no new key, no HTTP server.
70
+
71
+ A permission notification that carries a request id is raised with three
72
+ buttons: **Allow once**, **Always allow** and **Deny**. `notify-send` waits
73
+ for the click and prints the chosen button's *name* on stdout — `once`,
74
+ `always` or `reject`, and "reject" is opencode's own word for deny, not ours.
75
+ The name needs no translation: it is already one of the values the server
76
+ accepts as a permission reply. The plugin reads it off the helper's stdout and
77
+ answers the permission through opencode's own reply route.
78
+
79
+ Two things follow from the shape. An **empty** stdout means the popup was
80
+ dismissed without a button being chosen: nothing is answered, and the
81
+ permission is left exactly as it was. And a permission that was **already
82
+ answered** — most often in the terminal — comes back as "not found" rather
83
+ than as a failure, because the user did what they had to do and clicking a
84
+ stale notification is not an error.
85
+
86
+ The wait is bounded: ten minutes on the desktop side, and the hop's own
87
+ deadline is stretched to outlast it, because an answer cut off by a timeout is
88
+ worse than no answer at all.
89
+
90
+ The buttons are **Linux-only**. `notify-send --action` is the only one of the
91
+ three channels that can offer one — `osascript` and `terminal-notifier` have no
92
+ action support — so a macOS desktop gets a plain notice, and the answer there
93
+ still has to be given in the terminal.
94
+
51
95
  ## Setting it up
52
96
 
53
97
  Once, on the **remote host** (where opencode runs):
@@ -95,11 +139,20 @@ SOUND is optional and last: `none`, `done`, `question`, `permission` or `error`,
95
139
  one bundled tone per outcome. Leave it off and nothing plays; the line above
96
140
  without a fifth word is correct.
97
141
 
98
- `notify` is fire-and-forget by design. An unreachable desktop, a missing key, no
99
- notifier installed at all — all of them print a warning on stderr and exit 0. A
100
- notification must never be able to fail the process that tried to send one; an
101
- agent must not go down because a laptop went to sleep. `forward` is the
102
- exception: it exits with the remote's status, because its caller wants to know.
142
+ `LACODE_REQUEST_ID` in the environment is the one thing that makes a send
143
+ actionable: it is the permission request id, it is opaque, and it is set on the
144
+ helper by the plugin rather than by you. A send with it carries the three
145
+ buttons on Linux and prints the name of the clicked one on stdout; a send
146
+ without it is an ordinary notice that prints nothing.
147
+
148
+ A plain `notify` is fire-and-forget by design. An unreachable desktop, a
149
+ missing key, no notifier installed at all — all of them print a warning on
150
+ stderr and exit 0. A notification must never be able to fail the process that
151
+ tried to send one; an agent must not go down because a laptop went to sleep.
152
+ `forward` is the exception: it exits with the remote's status, because its
153
+ caller wants to know. An **actionable** notify is the other exception, and it
154
+ is bounded rather than open-ended: it waits for the click, because the answer
155
+ is the whole point of it, and it still exits 0 whatever happens.
103
156
 
104
157
  On macOS the text is passed to `osascript` as arguments to an `on run argv`
105
158
  handler, never spliced into the script source — a session title is not trusted
@@ -115,6 +168,14 @@ delivers locally through the platform channel — `notify-send` on Linux,
115
168
  channel as a local send, **the desktop half is not Linux-only**: a Mac desktop
116
169
  receives notifications too.
117
170
 
171
+ The buttons are the exception to that. Only `notify-send` can offer one, so a
172
+ payload with a request id is an actionable notification on Linux and a plain
173
+ notice — one that prints nothing — on macOS. What the receiver prints on stdout
174
+ is the chosen button's name, and that stdout is what carries the answer back up
175
+ the connection (see [The way back](#the-way-back)). The request id itself is
176
+ never passed to a command and never printed — only its emptiness is ever
177
+ tested.
178
+
118
179
  It never forwards: `SSH_CLIENT` is set for a forced command as well, so it calls
119
180
  the local channel directly rather than `notify()` (which would loop back).
120
181
 
@@ -152,6 +213,11 @@ documented with the other plugin options in the README. Set it to `0`, `false`,
152
213
  `off` or `no` to silence notifications; anything else — a typo included — leaves
153
214
  sound on, because a misspelt variable must not be how you turn a feature off.
154
215
 
216
+ `LACODE_REQUEST_ID` is absent for the same kind of reason, and it is not yours
217
+ to set either: the plugin puts the permission request id on the helper
218
+ process, and the helper carries it as the payload's sixth field. The hop only
219
+ ever asks whether it is there.
220
+
155
221
  ## The reverse forward
156
222
 
157
223
  A host that sets `OC_TUNNEL_PORT` and leaves `OC_LOCAL_HOST` unset reaches the
@@ -161,3 +227,7 @@ desktop through a reverse forward: `sendRemote` falls back to an ssh call to
161
227
  (`bin/oc-notify-receiver`). That receiver is the Linux end of the hop: it
162
228
  delivers through `notify-send`, and libnotify is a Linux thing, so a Mac cannot
163
229
  be this end of the tunnel.
230
+
231
+ The way back works the same way here: the receiver's stdout comes back down
232
+ the forward, on the connection the notification went up, so a click on one of
233
+ the three buttons is carried home the same way it is on the direct hop.
@@ -193,7 +193,7 @@ does not.
193
193
 
194
194
  **Story ID(s) for tests:** US-11
195
195
  **Acceptance criteria:**
196
- - [ ] Given a session went busy and is now idle, When the idle event fires, Then a `done` notification is delivered.
196
+ - [ ] Given a session went busy and is now idle, When the idle event fires, Then a `done` notification is delivered and stays on the desktop until the user closes it.
197
197
  - [ ] Given a session ends with an unrecognized error, When the error event fires, Then an `error` notification is delivered, it bypasses the duplicate-suppression cooldown, and it carries the error's own message.
198
198
  - [ ] Given a recognized rate-limit or quota error, or a user abort, When a session error event fires, Then no failure notification is raised — the pool already handles the former and the user already knows the latter.
199
199
  - [ ] Given a session that never went busy, When it goes idle, Then no notification is sent.
@@ -211,7 +211,7 @@ does not.
211
211
  - [ ] Given a question and a completion occur inside the same cooldown window, When both are evaluated, Then they are not deduplicated against each other.
212
212
 
213
213
  ### US-25: Notification when opencode asks for permission
214
- **As a** user, **I want** to be notified when opencode is blocked asking me for permission — including permission to reach a directory outside the worktree — **so that** a silent permission prompt is never mistaken for a hung agent.
214
+ **As a** user, **I want** to be notified when opencode is blocked asking me for permission — including permission to reach a directory outside the worktree — **so that** a silent permission prompt is never mistaken for a hung agent, and so that I can allow or deny it where the notification reaches me instead of going back to the terminal.
215
215
 
216
216
  **Story ID(s) for tests:** US-25
217
217
  **Acceptance criteria:**
@@ -219,8 +219,14 @@ does not.
219
219
  - [ ] Given opencode asks to reach a directory outside the worktree, When the prompt fires, Then the notification names that directory, resolved from `metadata.parentDir`/`metadata.filepath` and with the trailing glob trimmed, because a glob is not something the user can judge.
220
220
  - [ ] Given the permission is granted, denied, or dismissed, When `permission.replied` fires, Then no notification is sent — the prompt was the thing worth interrupting for, not the answer — and only the parked-session block is cleared.
221
221
  - [ ] Given a session is parked on a permission prompt, When the idle event fires, Then no "finished" notification is sent, because nothing finished.
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.
222
+ - [ ] Given the plugin observes a permission prompt, When it handles the event, Then it registers no `permission.ask` hook and mutates nothing in the payload — that hook's `output.status` is the permission decision itself, and deciding a prompt inside a handler is not the same as answering the one the user was asked for. A permission is answered through the server's own reply route, and only when the user says which answer they meant.
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
+ - [ ] Given a permission prompt on Linux, When the notification is delivered, Then it is exactly one notification carrying three buttons — **Allow once**, **Always allow** and **Deny** — and the click raises no second notification.
225
+ - [ ] Given one of those buttons is clicked, When the answer comes back over the hop the notification came up, Then the permission is answered with that button's meaning through the server's reply route; the button names are opencode's own reply values (`once`, `always` and `reject` — the server's word for deny), so a click needs no translation to become an answer.
226
+ - [ ] Given the popup is dismissed without a button being chosen, When the notification returns, Then nothing is answered and the permission is left exactly as it was.
227
+ - [ ] Given the permission was already answered in the terminal, When a button is clicked, Then the reply is a no-op and is not reported as a failure.
228
+ - [ ] Given the desktop is a macOS one, When the same prompt is delivered, Then it is a plain notice with no buttons, because `osascript` and `terminal-notifier` have no action support and the answer still has to be given in the terminal there.
229
+ - [ ] Given a question is asked, When its notification is delivered, Then it carries no buttons and answers nothing, because a question is not a permission: the v1 client this plugin has has no question API at all, and the v2 routes need a bound socket the default TUI does not have.
224
230
 
225
231
  ### US-29: Notification sounds distinguish outcomes and can be switched off
226
232
  **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",