@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/CHANGELOG.md +164 -2
- package/README.md +10 -7
- package/dist/agents.js +70 -11
- package/dist/commands.js +6 -3
- package/dist/index.js +329 -60
- package/dist/lacode.js +83 -23
- package/dist/pool.js +4 -4
- package/dist/tunnel.js +69 -20
- package/docs/CONTEXT.md +21 -6
- package/docs/OMNIROUTE.md +2 -2
- package/docs/TUNNEL.md +86 -16
- package/docs/USER-STORIES.md +9 -3
- package/package.json +1 -1
- package/presets/README.md +4 -4
- package/presets/free-tier.json +23 -7
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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({
|
|
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/
|
|
55
|
-
{ index: 2, model: "opencode-go/
|
|
56
|
-
{ index: 3, model: "opencode/
|
|
57
|
-
{ index: 4, model: "opencode/
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
107
|
-
opencode-go/
|
|
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
|
-
│ │
|
|
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
|
|
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
|
|
33
|
-
string `"a b c d e"`; a receiver reading `$1..$
|
|
34
|
-
back to
|
|
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
|
-
|
|
42
|
-
`done`, `question`, `permission` or `error`.
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
`
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -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
|
|
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
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
|
|
40
|
-
opencode/
|
|
41
|
-
opencode/
|
|
42
|
-
opencode/
|
|
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
|