@lawaty/lacode 0.8.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/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/pool.js CHANGED
@@ -51,10 +51,10 @@ var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
51
51
  var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
52
52
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
53
53
  var FALLBACK_SLOTS = [
54
- { index: 1, model: "opencode/space-bunny-free", weight: 3 },
55
- { index: 2, model: "opencode-go/longcat-2.5-preview-free", weight: 2 },
56
- { index: 3, model: "opencode/big-pickle", weight: 1 },
57
- { index: 4, model: "opencode/longcat-2.5-preview-free", weight: 1 }
54
+ { index: 1, model: "opencode/big-pickle", weight: 3 },
55
+ { index: 2, model: "opencode-go/minimax-m3.1-flash-free", weight: 2 },
56
+ { index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
57
+ { index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
58
58
  ];
59
59
  function parseSlots(parsed, origin) {
60
60
  const problems = [];
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/CONTEXT.md CHANGED
@@ -29,10 +29,11 @@ the exploration it replaces is worse than no map:
29
29
  for order mutations", not "there is a file called OrderService in
30
30
  `src/orders/`". The cartographer is forbidden by name from writing directory
31
31
  listings, copying source, or restating what the README already says.
32
- - **One file per question.** Five files, each with a defined scope, so an agent
33
- reads one of them rather than all of them.
32
+ - **One file per question.** Five navigation files, each with a defined scope, so
33
+ an agent reads one of them rather than all of them. A sixth file,
34
+ `active.md`, holds the work in flight rather than a map of the repository.
34
35
  - **Canonical entry points.** The highest-value thing in the map is a short
35
- "Start here" list per subsystem: the five files an agent should open, each
36
+ "Start here" list per subsystem: the canonical files an agent should open, each
36
37
  with a one-phrase reason.
37
38
  - **It links out.** Your project's own README, ADRs and docs are authoritative
38
39
  and are not duplicated here.
@@ -47,7 +48,8 @@ harness that produces a number for your own workload.
47
48
 
48
49
  ## The files in the map
49
50
 
50
- `.opencode/context/` holds the five generated files below:
51
+ `.opencode/context/` holds the five navigation files below, plus one file that is
52
+ a work list rather than a map:
51
53
 
52
54
  | File | What belongs in it |
53
55
  | --- | --- |
@@ -56,6 +58,7 @@ harness that produces a number for your own workload.
56
58
  | `conventions.md` | Repository-specific rules: naming, module layout, error handling, testing, persistence. Read this before writing code, not after. |
57
59
  | `workflows.md` | The few runtime flows worth tracing end to end — how a request becomes a mutation, how a plugin hook reaches the thing it hooks. Read this when you need to know what actually happens at runtime. |
58
60
  | `decisions.md` | Architectural decisions that the repository's own decision log does not already record. Concise, variable length. |
61
+ | `active.md` | The active backlog: work in flight, grouped by the ISO week each task was opened in. A task that is not marked `[x]` is open and is never removed; a task that is finished and dated is kept for a week after it was finished, then dropped. The plugin enforces that window — see [How it stays current](#how-it-stays-current). Not a navigation file: there is no question it is the answer to. |
59
62
 
60
63
  The project's root `AGENTS.md` is the standing-instructions home: the client's
61
64
  own statement of intent, what they want worked on and how they want it
@@ -135,6 +138,18 @@ your source.
135
138
  If you have defined a command of the same name, yours wins and is left exactly
136
139
  as you wrote it — the same merge-without-clobber rule the agents follow.
137
140
 
141
+ `active.md` is the one file in the directory the plugin maintains rather than
142
+ the cartographer. The cartographer records what happened — work in flight, and a
143
+ task marked finished with the date it finished — and the plugin decides what is
144
+ still worth keeping: the file is regrouped by week and a task that was finished
145
+ more than a week ago is dropped. The arithmetic lives in
146
+ `src/lib/active-backlog.ts`, not in the cartographer's prompt, because a rule a
147
+ model applies is a rule the model applies when it feels like it, and the failure
148
+ is silent — the backlog just grows. The plugin normalizes the file on every
149
+ edit of it and once when the session starts, so a hand edit is grouped and
150
+ pruned whether the cartographer ran or not, and the file is rewritten only when
151
+ its canonical form differs from what is on disk, so the write cannot loop.
152
+
138
153
  ## One writer
139
154
 
140
155
  Only `context` writes the map. Every other agent is told never to edit
@@ -152,7 +167,7 @@ naming the one writer, and the permission rule — a permission denial tells an
152
167
  agent nothing about what it should have done instead.
153
168
 
154
169
  This exists because two writers produce a map that is neither current nor
155
- trustworthy. Concurrent sessions rewriting the same five files produce
170
+ trustworthy. Concurrent sessions rewriting the same files produce
156
171
  contradictions that no later reader can resolve, and the reader has no way to
157
172
  tell which version is newer.
158
173
 
@@ -193,7 +208,7 @@ startup check has no way to see a worktree it was not told about.
193
208
 
194
209
  How to detect it: open opencode from the repository root, not from a
195
210
  subdirectory of it. If `ls .opencode/context` from your shell's current
196
- directory does not list the five files, your shell is not at the root and
211
+ directory does not list the map files, your shell is not at the root and
197
212
  neither is opencode's worktree. The cost of getting it wrong is not a crash —
198
213
  it is a map maintained only by prompt, with no mechanism enforcing who writes it.
199
214
 
package/docs/OMNIROUTE.md CHANGED
@@ -103,8 +103,8 @@ work (exploration, implementation, cartography) through the gateway, add an
103
103
  ```
104
104
  # ~/.config/lacode/pool.txt
105
105
  omniroute/free-mix
106
- opencode/space-bunny-free
107
- opencode-go/longcat-2.5-preview-free
106
+ opencode/big-pickle
107
+ opencode-go/minimax-m3.1-flash-free
108
108
  ```
109
109
 
110
110
  OmniRoute is a provider like any other: the pool decides its place and weight,
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.8.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",
package/presets/README.md CHANGED
@@ -36,10 +36,10 @@ in place, no JSON and no objects:
36
36
 
37
37
  ```text
38
38
  # provider/model-id weight
39
- opencode-go/longcat-2.5-preview-free 3
40
- opencode/space-bunny-free 2
41
- opencode/big-pickle 1
42
- opencode/longcat-2.5-preview-free 1
39
+ opencode/big-pickle 3
40
+ opencode-go/minimax-m3.1-flash-free 2
41
+ opencode/deepseek-v4.1-flash-free 1
42
+ opencode/glm-5.3-flash-free 1
43
43
  ```
44
44
 
45
45
  `#` starts a comment (whole-line or trailing), blank lines are ignored, and the