@lawaty/lacode 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -56
- package/dist/agents.js +47 -240
- package/dist/commands.js +6 -6
- package/dist/index.js +145 -274
- package/dist/lacode.js +115 -27
- package/dist/pool.js +25 -2
- package/dist/tunnel.js +76 -25
- package/docs/CONTEXT.md +4 -4
- package/docs/TUNNEL.md +50 -8
- package/docs/USER-STORIES.md +41 -15
- package/package.json +4 -2
- package/presets/free-tier.json +1 -1
package/dist/lacode.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// bin/lacode
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
5
7
|
|
|
6
8
|
// src/tunnel/index.ts
|
|
7
9
|
import { spawnSync } from "node:child_process";
|
|
@@ -19,6 +21,7 @@ function resolveTunnel(env = process.env) {
|
|
|
19
21
|
const port = Number(env.OC_TUNNEL_PORT);
|
|
20
22
|
return {
|
|
21
23
|
user: env.OC_LOCAL_USER ?? "",
|
|
24
|
+
host: env.OC_LOCAL_HOST ?? "",
|
|
22
25
|
port: Number.isFinite(port) && port > 0 ? port : 10022,
|
|
23
26
|
key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
|
|
24
27
|
connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
|
|
@@ -41,6 +44,15 @@ var safeText = (raw, fallback, max) => {
|
|
|
41
44
|
var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
|
|
42
45
|
var safeMessage = (raw) => safeText(raw ?? "", "", 400);
|
|
43
46
|
var FIELD_SEPARATOR = "\n";
|
|
47
|
+
function parsePayload(raw) {
|
|
48
|
+
const [title, message, urgency, icon] = raw.split(FIELD_SEPARATOR);
|
|
49
|
+
return {
|
|
50
|
+
title: safeTitle(title),
|
|
51
|
+
message: safeMessage(message),
|
|
52
|
+
urgency: normalizeUrgency(urgency),
|
|
53
|
+
icon: normalizeIcon(icon)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
44
56
|
var encodePayload = (request) => [
|
|
45
57
|
safeTitle(request.title),
|
|
46
58
|
safeMessage(request.message),
|
|
@@ -59,6 +71,14 @@ var SSH_OPTS = [
|
|
|
59
71
|
"-o",
|
|
60
72
|
"StrictHostKeyChecking=accept-new"
|
|
61
73
|
];
|
|
74
|
+
var CONTROL_OPTS = [
|
|
75
|
+
"-o",
|
|
76
|
+
"ControlMaster=auto",
|
|
77
|
+
"-o",
|
|
78
|
+
"ControlPersist=300",
|
|
79
|
+
"-o",
|
|
80
|
+
`ControlPath=${join(homedir(), ".ssh", "lacode-cm-%C")}`
|
|
81
|
+
];
|
|
62
82
|
var run = (command2, args, timeout) => {
|
|
63
83
|
try {
|
|
64
84
|
const result = spawnSync(command2, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -81,37 +101,64 @@ var have = (command2) => {
|
|
|
81
101
|
return false;
|
|
82
102
|
}
|
|
83
103
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
var CHANNEL_BINARY = {
|
|
105
|
+
"notify-send": "notify-send",
|
|
106
|
+
osascript: "osascript",
|
|
107
|
+
"terminal-notifier": "terminal-notifier"
|
|
108
|
+
};
|
|
109
|
+
var DARWIN_SCRIPT = [
|
|
110
|
+
"on run argv",
|
|
111
|
+
" display notification (item 3 of argv) with title (item 2 of argv)",
|
|
112
|
+
"end run"
|
|
113
|
+
].join("\n");
|
|
114
|
+
var DARWIN_ARGV_MARKER = "lacode";
|
|
115
|
+
function localChannel(platform = process.platform) {
|
|
116
|
+
if (platform === "darwin") {
|
|
117
|
+
if (have(CHANNEL_BINARY.osascript)) return "osascript";
|
|
118
|
+
if (have(CHANNEL_BINARY["terminal-notifier"])) return "terminal-notifier";
|
|
119
|
+
return "none";
|
|
120
|
+
}
|
|
121
|
+
return have(CHANNEL_BINARY["notify-send"]) ? "notify-send" : "none";
|
|
122
|
+
}
|
|
123
|
+
var NO_CHANNEL = {
|
|
124
|
+
linux: "notify-send is not installed",
|
|
125
|
+
darwin: "neither osascript nor terminal-notifier is installed"
|
|
126
|
+
};
|
|
127
|
+
function sendLocal(request, platform = process.platform) {
|
|
128
|
+
const channel = localChannel(platform);
|
|
129
|
+
if (channel === "none") {
|
|
130
|
+
return { ok: false, detail: NO_CHANNEL[platform] ?? `no local notification channel on ${platform}`, transport: "local" };
|
|
131
|
+
}
|
|
132
|
+
const title = safeTitle(request.title);
|
|
133
|
+
const body = safeMessage(request.message);
|
|
134
|
+
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)] : (
|
|
135
|
+
// Byte-identical to the notify-send call this package has always made.
|
|
136
|
+
[title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
|
|
137
|
+
);
|
|
138
|
+
const result = run(CHANNEL_BINARY[channel], args, 5e3);
|
|
93
139
|
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
|
|
94
140
|
}
|
|
95
141
|
function sendRemote(request, tunnel) {
|
|
96
142
|
if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
143
|
+
const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
|
|
144
|
+
if (tunnel.host) {
|
|
145
|
+
const result2 = run(
|
|
146
|
+
"ssh",
|
|
147
|
+
[...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
|
|
148
|
+
tunnel.connectTimeoutMs + 2e3
|
|
149
|
+
);
|
|
150
|
+
return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
|
|
151
|
+
}
|
|
152
|
+
const result = run(
|
|
153
|
+
"ssh",
|
|
154
|
+
[...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
|
|
155
|
+
tunnel.connectTimeoutMs + 2e3
|
|
156
|
+
);
|
|
110
157
|
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
|
|
111
158
|
}
|
|
112
|
-
function notify(request, env = process.env) {
|
|
159
|
+
function notify(request, env = process.env, platform = process.platform) {
|
|
113
160
|
try {
|
|
114
|
-
return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request);
|
|
161
|
+
return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
|
|
115
162
|
} catch (error) {
|
|
116
163
|
return { ok: false, detail: String(error), transport: "none" };
|
|
117
164
|
}
|
|
@@ -127,13 +174,17 @@ function forward(host, argv2, options = {}) {
|
|
|
127
174
|
stdout: result.stdout
|
|
128
175
|
};
|
|
129
176
|
}
|
|
130
|
-
function describe(env = process.env) {
|
|
177
|
+
function describe(env = process.env, platform = process.platform) {
|
|
131
178
|
const tunnel = resolveTunnel(env);
|
|
132
179
|
return {
|
|
133
180
|
running: isRemote(env) ? "remote (over ssh)" : "local",
|
|
134
181
|
user: tunnel.user || "(unset)",
|
|
182
|
+
host: tunnel.host || "(unset)",
|
|
135
183
|
port: tunnel.port,
|
|
136
184
|
key: tunnel.key,
|
|
185
|
+
channel: localChannel(platform),
|
|
186
|
+
// Still exactly what it always meant -- is the notify-send binary present --
|
|
187
|
+
// rather than a second opinion about which channel runs.
|
|
137
188
|
notifySend: have("notify-send")
|
|
138
189
|
};
|
|
139
190
|
}
|
|
@@ -144,8 +195,8 @@ if (!process.features?.typescript) {
|
|
|
144
195
|
console.error("lacode: this node cannot load TypeScript sources; node >= 22.18 is required.");
|
|
145
196
|
process.exit(1);
|
|
146
197
|
}
|
|
147
|
-
const { spawnSync:
|
|
148
|
-
const result =
|
|
198
|
+
const { spawnSync: spawnSync3 } = await import("node:child_process");
|
|
199
|
+
const result = spawnSync3(
|
|
149
200
|
process.execPath,
|
|
150
201
|
["--experimental-strip-types", "--no-warnings", fileURLToPath(import.meta.url), ...process.argv.slice(2)],
|
|
151
202
|
{ stdio: "inherit", env: { ...process.env, LACODE_STRIP_TYPECHECKED: "1" } }
|
|
@@ -212,7 +263,44 @@ switch (command) {
|
|
|
212
263
|
if (info.running.startsWith("remote")) {
|
|
213
264
|
if (info.user === "(unset)") console.log("\nOC_LOCAL_USER is unset: the hop cannot name the desktop user, and guessing with `id -un` is wrong on most remote accounts.");
|
|
214
265
|
console.log("Set OC_LOCAL_USER, OC_TUNNEL_PORT and OC_NOTIFY_KEY, or point OC_NOTIFY_HELPER at a helper that knows them.");
|
|
266
|
+
} else if (info.channel === "none") {
|
|
267
|
+
console.log("\nNo local notification channel: `lacode notify` on this machine has nothing to deliver with, and every send will be reported as failed rather than shown.");
|
|
268
|
+
}
|
|
269
|
+
process.exit(0);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case "receive": {
|
|
273
|
+
const request = parsePayload(process.env.SSH_ORIGINAL_COMMAND ?? "");
|
|
274
|
+
const result = sendLocal(request);
|
|
275
|
+
if (!result.ok) console.error(`lacode receive: ${result.detail ?? "not delivered"}`);
|
|
276
|
+
process.exit(result.ok ? 0 : 1);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
case "tunnel": {
|
|
280
|
+
const sub = argv.shift();
|
|
281
|
+
if (sub !== "setup") {
|
|
282
|
+
console.error("lacode tunnel: usage: lacode tunnel setup");
|
|
283
|
+
process.exit(2);
|
|
284
|
+
}
|
|
285
|
+
const { user, host, key } = resolveTunnel();
|
|
286
|
+
const pub = `${key}.pub`;
|
|
287
|
+
if (!existsSync(key)) {
|
|
288
|
+
const gen = spawnSync2("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", "lacode-notify", "-f", key], { stdio: ["ignore", "ignore", "inherit"] });
|
|
289
|
+
if (gen.status !== 0) {
|
|
290
|
+
console.error("lacode tunnel setup: ssh-keygen failed; create the key by hand and re-run.");
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
console.log(`Generated ${key}`);
|
|
215
294
|
}
|
|
295
|
+
const pubkey = existsSync(pub) ? readFileSync(pub, "utf8").trim() : "(public key missing)";
|
|
296
|
+
const self = process.argv[1] ?? "lacode";
|
|
297
|
+
console.log("\nOn the REMOTE host (where opencode runs), export:");
|
|
298
|
+
console.log(` OC_LOCAL_USER=${user || "<desktop-user>"}`);
|
|
299
|
+
console.log(` OC_LOCAL_HOST=${host || "<desktop-host>"}`);
|
|
300
|
+
console.log(` OC_NOTIFY_KEY=${key}`);
|
|
301
|
+
console.log("\nAdd ONE line to the DESKTOP's ~/.ssh/authorized_keys (this is the trust boundary):");
|
|
302
|
+
console.log(` restrict,command="${self} receive" ${pubkey}`);
|
|
303
|
+
console.log("\nDirect ssh + ControlPersist: no reverse forward, no systemd unit, no port, nothing to sync.");
|
|
216
304
|
process.exit(0);
|
|
217
305
|
break;
|
|
218
306
|
}
|
package/dist/pool.js
CHANGED
|
@@ -10,12 +10,13 @@ var CATALOG_TTL_MS = 10 * 60 * 1e3;
|
|
|
10
10
|
var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
|
|
11
11
|
var STUCK_IDLE_MS = 3 * 60 * 1e3;
|
|
12
12
|
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
13
|
+
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
13
14
|
var MAX_WEIGHT = 100;
|
|
14
15
|
var MAX_SLOTS = 4;
|
|
15
16
|
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
16
17
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
17
18
|
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
18
|
-
var BASES = ["explore
|
|
19
|
+
var BASES = ["explore", "implement", "context"];
|
|
19
20
|
var findPackageRoot = (start) => {
|
|
20
21
|
let dir = start;
|
|
21
22
|
for (let up = 0; up < 6; up++) {
|
|
@@ -141,6 +142,7 @@ function readWeight(entry, at, model, problems) {
|
|
|
141
142
|
}
|
|
142
143
|
return weight;
|
|
143
144
|
}
|
|
145
|
+
var UNAVAILABLE_PATTERN = /endpoint\s+is\s+unavailable/i;
|
|
144
146
|
var LIMIT_PATTERNS = [
|
|
145
147
|
/rate[\s_-]?limit/i,
|
|
146
148
|
/usage limit/i,
|
|
@@ -148,9 +150,11 @@ var LIMIT_PATTERNS = [
|
|
|
148
150
|
/\b429\b/,
|
|
149
151
|
/quota/i,
|
|
150
152
|
/freelimiterror/i,
|
|
151
|
-
/overloaded/i
|
|
153
|
+
/overloaded/i,
|
|
154
|
+
UNAVAILABLE_PATTERN
|
|
152
155
|
];
|
|
153
156
|
var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text));
|
|
157
|
+
var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
|
|
154
158
|
var statedCooldown = (text) => {
|
|
155
159
|
const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
|
|
156
160
|
if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
|
|
@@ -332,6 +336,22 @@ function recordStrike(s, model, reason, stated) {
|
|
|
332
336
|
}
|
|
333
337
|
return strike;
|
|
334
338
|
}
|
|
339
|
+
function coolUnavailable(s, model) {
|
|
340
|
+
if (!s.dirReady) return;
|
|
341
|
+
const now = Date.now();
|
|
342
|
+
const prior = s.cooling.get(model);
|
|
343
|
+
const until = now + UNAVAILABLE_COOLDOWN_MS;
|
|
344
|
+
if (prior && prior.until > until) return prior;
|
|
345
|
+
const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
|
|
346
|
+
s.cooling.set(model, strike);
|
|
347
|
+
const path = join(s.dir, limitFile(model));
|
|
348
|
+
try {
|
|
349
|
+
writeFileSync(`${path}.tmp`, JSON.stringify(strike));
|
|
350
|
+
renameSync(`${path}.tmp`, path);
|
|
351
|
+
} catch {
|
|
352
|
+
}
|
|
353
|
+
return strike;
|
|
354
|
+
}
|
|
335
355
|
function refreshCatalog(s, serverUrl) {
|
|
336
356
|
if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
|
|
337
357
|
s.catalogAt = Date.now();
|
|
@@ -448,6 +468,7 @@ export {
|
|
|
448
468
|
PRESETS_DIR,
|
|
449
469
|
STUCK_IDLE_MS,
|
|
450
470
|
STUCK_MIN_AGE_MS,
|
|
471
|
+
UNAVAILABLE_COOLDOWN_MS,
|
|
451
472
|
USER_CONFIG_DIR,
|
|
452
473
|
USER_MODELS_PATH,
|
|
453
474
|
acquire,
|
|
@@ -455,6 +476,7 @@ export {
|
|
|
455
476
|
bindChild,
|
|
456
477
|
claimKey,
|
|
457
478
|
coolProvider,
|
|
479
|
+
coolUnavailable,
|
|
458
480
|
ensureSlots,
|
|
459
481
|
ensureUserConfigDir,
|
|
460
482
|
findStuck,
|
|
@@ -462,6 +484,7 @@ export {
|
|
|
462
484
|
init,
|
|
463
485
|
isAvailable,
|
|
464
486
|
isLimitText,
|
|
487
|
+
isUnavailableText,
|
|
465
488
|
noteActivity,
|
|
466
489
|
parseSlots,
|
|
467
490
|
pickSlot,
|
package/dist/tunnel.js
CHANGED
|
@@ -14,6 +14,7 @@ function resolveTunnel(env = process.env) {
|
|
|
14
14
|
const port = Number(env.OC_TUNNEL_PORT);
|
|
15
15
|
return {
|
|
16
16
|
user: env.OC_LOCAL_USER ?? "",
|
|
17
|
+
host: env.OC_LOCAL_HOST ?? "",
|
|
17
18
|
port: Number.isFinite(port) && port > 0 ? port : 10022,
|
|
18
19
|
key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
|
|
19
20
|
connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
|
|
@@ -36,6 +37,15 @@ var safeText = (raw, fallback, max) => {
|
|
|
36
37
|
var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
|
|
37
38
|
var safeMessage = (raw) => safeText(raw ?? "", "", 400);
|
|
38
39
|
var FIELD_SEPARATOR = "\n";
|
|
40
|
+
function parsePayload(raw) {
|
|
41
|
+
const [title, message, urgency, icon] = raw.split(FIELD_SEPARATOR);
|
|
42
|
+
return {
|
|
43
|
+
title: safeTitle(title),
|
|
44
|
+
message: safeMessage(message),
|
|
45
|
+
urgency: normalizeUrgency(urgency),
|
|
46
|
+
icon: normalizeIcon(icon)
|
|
47
|
+
};
|
|
48
|
+
}
|
|
39
49
|
var encodePayload = (request) => [
|
|
40
50
|
safeTitle(request.title),
|
|
41
51
|
safeMessage(request.message),
|
|
@@ -54,6 +64,14 @@ var SSH_OPTS = [
|
|
|
54
64
|
"-o",
|
|
55
65
|
"StrictHostKeyChecking=accept-new"
|
|
56
66
|
];
|
|
67
|
+
var CONTROL_OPTS = [
|
|
68
|
+
"-o",
|
|
69
|
+
"ControlMaster=auto",
|
|
70
|
+
"-o",
|
|
71
|
+
"ControlPersist=300",
|
|
72
|
+
"-o",
|
|
73
|
+
`ControlPath=${join(homedir(), ".ssh", "lacode-cm-%C")}`
|
|
74
|
+
];
|
|
57
75
|
var run = (command, args, timeout) => {
|
|
58
76
|
try {
|
|
59
77
|
const result = spawnSync(command, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -76,37 +94,64 @@ var have = (command) => {
|
|
|
76
94
|
return false;
|
|
77
95
|
}
|
|
78
96
|
};
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
var CHANNEL_BINARY = {
|
|
98
|
+
"notify-send": "notify-send",
|
|
99
|
+
osascript: "osascript",
|
|
100
|
+
"terminal-notifier": "terminal-notifier"
|
|
101
|
+
};
|
|
102
|
+
var DARWIN_SCRIPT = [
|
|
103
|
+
"on run argv",
|
|
104
|
+
" display notification (item 3 of argv) with title (item 2 of argv)",
|
|
105
|
+
"end run"
|
|
106
|
+
].join("\n");
|
|
107
|
+
var DARWIN_ARGV_MARKER = "lacode";
|
|
108
|
+
function localChannel(platform = process.platform) {
|
|
109
|
+
if (platform === "darwin") {
|
|
110
|
+
if (have(CHANNEL_BINARY.osascript)) return "osascript";
|
|
111
|
+
if (have(CHANNEL_BINARY["terminal-notifier"])) return "terminal-notifier";
|
|
112
|
+
return "none";
|
|
113
|
+
}
|
|
114
|
+
return have(CHANNEL_BINARY["notify-send"]) ? "notify-send" : "none";
|
|
115
|
+
}
|
|
116
|
+
var NO_CHANNEL = {
|
|
117
|
+
linux: "notify-send is not installed",
|
|
118
|
+
darwin: "neither osascript nor terminal-notifier is installed"
|
|
119
|
+
};
|
|
120
|
+
function sendLocal(request, platform = process.platform) {
|
|
121
|
+
const channel = localChannel(platform);
|
|
122
|
+
if (channel === "none") {
|
|
123
|
+
return { ok: false, detail: NO_CHANNEL[platform] ?? `no local notification channel on ${platform}`, transport: "local" };
|
|
124
|
+
}
|
|
125
|
+
const title = safeTitle(request.title);
|
|
126
|
+
const body = safeMessage(request.message);
|
|
127
|
+
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)] : (
|
|
128
|
+
// Byte-identical to the notify-send call this package has always made.
|
|
129
|
+
[title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
|
|
130
|
+
);
|
|
131
|
+
const result = run(CHANNEL_BINARY[channel], args, 5e3);
|
|
88
132
|
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
|
|
89
133
|
}
|
|
90
134
|
function sendRemote(request, tunnel) {
|
|
91
135
|
if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
136
|
+
const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
|
|
137
|
+
if (tunnel.host) {
|
|
138
|
+
const result2 = run(
|
|
139
|
+
"ssh",
|
|
140
|
+
[...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
|
|
141
|
+
tunnel.connectTimeoutMs + 2e3
|
|
142
|
+
);
|
|
143
|
+
return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
|
|
144
|
+
}
|
|
145
|
+
const result = run(
|
|
146
|
+
"ssh",
|
|
147
|
+
[...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
|
|
148
|
+
tunnel.connectTimeoutMs + 2e3
|
|
149
|
+
);
|
|
105
150
|
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
|
|
106
151
|
}
|
|
107
|
-
function notify(request, env = process.env) {
|
|
152
|
+
function notify(request, env = process.env, platform = process.platform) {
|
|
108
153
|
try {
|
|
109
|
-
return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request);
|
|
154
|
+
return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
|
|
110
155
|
} catch (error) {
|
|
111
156
|
return { ok: false, detail: String(error), transport: "none" };
|
|
112
157
|
}
|
|
@@ -122,13 +167,17 @@ function forward(host, argv, options = {}) {
|
|
|
122
167
|
stdout: result.stdout
|
|
123
168
|
};
|
|
124
169
|
}
|
|
125
|
-
function describe(env = process.env) {
|
|
170
|
+
function describe(env = process.env, platform = process.platform) {
|
|
126
171
|
const tunnel = resolveTunnel(env);
|
|
127
172
|
return {
|
|
128
173
|
running: isRemote(env) ? "remote (over ssh)" : "local",
|
|
129
174
|
user: tunnel.user || "(unset)",
|
|
175
|
+
host: tunnel.host || "(unset)",
|
|
130
176
|
port: tunnel.port,
|
|
131
177
|
key: tunnel.key,
|
|
178
|
+
channel: localChannel(platform),
|
|
179
|
+
// Still exactly what it always meant -- is the notify-send binary present --
|
|
180
|
+
// rather than a second opinion about which channel runs.
|
|
132
181
|
notifySend: have("notify-send")
|
|
133
182
|
};
|
|
134
183
|
}
|
|
@@ -139,9 +188,11 @@ export {
|
|
|
139
188
|
encodePayload,
|
|
140
189
|
forward,
|
|
141
190
|
isRemote,
|
|
191
|
+
localChannel,
|
|
142
192
|
normalizeIcon,
|
|
143
193
|
normalizeUrgency,
|
|
144
194
|
notify,
|
|
195
|
+
parsePayload,
|
|
145
196
|
resolveTunnel,
|
|
146
197
|
safeMessage,
|
|
147
198
|
safeTitle,
|
package/docs/CONTEXT.md
CHANGED
|
@@ -69,14 +69,14 @@ something has gone wrong.
|
|
|
69
69
|
|
|
70
70
|
## How it stays current
|
|
71
71
|
|
|
72
|
-
The `context
|
|
72
|
+
The `context` agent maintains the map. It runs in two ways: on its own,
|
|
73
73
|
after a session that changed something, and on demand through the three
|
|
74
74
|
commands — both of which go through the same agent, so they behave the same way.
|
|
75
75
|
|
|
76
76
|
### Automatically
|
|
77
77
|
|
|
78
78
|
After a **root** session goes idle, and only if that session edited a file
|
|
79
|
-
inside the project, LaCode starts a `context
|
|
79
|
+
inside the project, LaCode starts a `context` run:
|
|
80
80
|
|
|
81
81
|
1. It collects the paths the session touched, dropping anything generated,
|
|
82
82
|
vendored or outside the project (`node_modules`, `dist`, `coverage`,
|
|
@@ -123,7 +123,7 @@ as you wrote it — the same merge-without-clobber rule the agents follow.
|
|
|
123
123
|
|
|
124
124
|
## One writer
|
|
125
125
|
|
|
126
|
-
Only `context
|
|
126
|
+
Only `context` writes the map. Every other agent is told never to edit
|
|
127
127
|
it, and to say in its final report if its work revealed something that belongs
|
|
128
128
|
in there. The cartographer, in turn, is denied permission to write anything
|
|
129
129
|
outside the map — not by convention, but by an `edit` rule that allows
|
|
@@ -199,7 +199,7 @@ thing to check is that permission rule, not the map's content.
|
|
|
199
199
|
| `auto update finished without changing the map` | Nothing landed. Either nothing needed saying, or the cartographer could not write. |
|
|
200
200
|
| `auto update failed: …` | The run errored. The paths it was given are queued again for the next window, so the work is not lost. |
|
|
201
201
|
| `context map is not writable: …` | Your config's one-writer rule does not match the path opencode evaluates. Nothing will update until you fix it. |
|
|
202
|
-
| `context map has more than one writer: …` | An agent other than `context
|
|
202
|
+
| `context map has more than one writer: …` | An agent other than `context` would be granted the map. The offending agent and the winning rule are named. |
|
|
203
203
|
|
|
204
204
|
## Reference
|
|
205
205
|
|
package/docs/TUNNEL.md
CHANGED
|
@@ -19,7 +19,15 @@ gets it back.
|
|
|
19
19
|
└──────────────────┘ └────────────────────────┘
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
**Direct ssh (the default).** Set `OC_LOCAL_HOST` and the hop is a plain `ssh`
|
|
23
|
+
to the desktop with `ControlMaster=auto` + `ControlPersist`, so one warm
|
|
24
|
+
connection carries every notification. There is no reverse forward, no listening
|
|
25
|
+
port, no systemd unit and nothing to sync: the plugin owns the transport. The
|
|
26
|
+
desktop needs one `authorized_keys` line, which `lacode tunnel setup` prints. The
|
|
27
|
+
reverse-forward description below is the legacy path, used only when
|
|
28
|
+
`OC_LOCAL_HOST` is unset.
|
|
29
|
+
|
|
30
|
+
**Reverse forward (legacy).** Two things are load-bearing here.
|
|
23
31
|
|
|
24
32
|
**The reverse forward.** The remote opens an SSH connection to *your* machine
|
|
25
33
|
and asks sshd on the desktop to listen on a port. A notification is then an ssh
|
|
@@ -46,7 +54,9 @@ spaces, quotes or `*` survives intact.
|
|
|
46
54
|
## Sending
|
|
47
55
|
|
|
48
56
|
The CLI picks the transport by where it is running — `SSH_CLIENT` or `SSH_TTY`
|
|
49
|
-
means remote, so it forwards; otherwise it
|
|
57
|
+
means remote, so it forwards; otherwise it delivers locally through whichever
|
|
58
|
+
desktop notifier this platform has: `notify-send` everywhere else, `osascript` on
|
|
59
|
+
macOS, with `terminal-notifier` as the macOS fallback when `osascript` is missing.
|
|
50
60
|
|
|
51
61
|
```bash
|
|
52
62
|
lacode notify "build finished" "lapode: 4 files changed" normal dialog-information
|
|
@@ -55,19 +65,25 @@ lacode forward web-01 -- systemctl --user status oc-tunnel
|
|
|
55
65
|
lacode doctor
|
|
56
66
|
```
|
|
57
67
|
|
|
58
|
-
`notify` is fire-and-forget by design. An unreachable desktop, a missing key,
|
|
59
|
-
|
|
68
|
+
`notify` is fire-and-forget by design. An unreachable desktop, a missing key, no
|
|
69
|
+
notifier installed at all — all of them print a warning on stderr and exit 0. A
|
|
60
70
|
notification must never be able to fail the process that tried to send one; an
|
|
61
71
|
agent must not go down because a laptop went to sleep. `forward` is the
|
|
62
72
|
exception: it exits with the remote's status, because its caller wants to know.
|
|
63
73
|
|
|
74
|
+
On macOS the text is passed to `osascript` as arguments to an `on run argv`
|
|
75
|
+
handler, never spliced into the script source — a session title is not trusted
|
|
76
|
+
input. `lacode doctor` prints the channel that would actually run, so it is
|
|
77
|
+
useful on a Mac rather than reporting only whether a Linux binary is installed.
|
|
78
|
+
|
|
64
79
|
`lacode doctor` prints what the environment resolves to and never gates on it.
|
|
65
80
|
|
|
66
81
|
## Configuration
|
|
67
82
|
|
|
68
83
|
| Variable | Default | Meaning |
|
|
69
84
|
| --- | --- | --- |
|
|
70
|
-
| `OC_LOCAL_USER` | *(unset)* | The desktop user the
|
|
85
|
+
| `OC_LOCAL_USER` | *(unset)* | The desktop user the hop authenticates as. **Required for the remote hop.** |
|
|
86
|
+
| `OC_LOCAL_HOST` | *(unset)* | Desktop hostname for the **direct ssh** hop. Set it and the reverse forward/port/systemd unit are not used at all. |
|
|
71
87
|
| `OC_TUNNEL_PORT` | `10022` | Reverse-forward listener port on the desktop. Give each remote host its own. |
|
|
72
88
|
| `OC_NOTIFY_KEY` | `~/.ssh/lacode_notify` | The dedicated key. Never a personal key. |
|
|
73
89
|
| `OC_CONNECT_TIMEOUT_MS` | `5000` | Ceiling on the hop. |
|
|
@@ -80,10 +96,12 @@ nobody's desktop. Guessing the desktop user is worse than failing.
|
|
|
80
96
|
## Setting the tunnel up on the desktop
|
|
81
97
|
|
|
82
98
|
The receiver runs on the machine with the desktop, as an `authorized_keys`
|
|
83
|
-
forced command restricted to that one script
|
|
99
|
+
forced command restricted to that one script. `$HOME` must be the expanded
|
|
100
|
+
absolute path — a `~` is not expanded there, and an unexpanded one produces an
|
|
101
|
+
`authorized_keys` entry that names a file that does not exist:
|
|
84
102
|
|
|
85
103
|
```
|
|
86
|
-
restrict,command="/home/
|
|
104
|
+
restrict,command="/home/yourname/.local/bin/lacode-notify-receiver" ssh-ed25519 AAAA… oc-notify tunnel auth
|
|
87
105
|
```
|
|
88
106
|
|
|
89
107
|
`restrict` is doing the security work: no shell, no PTY, no agent forwarding, no
|
|
@@ -96,12 +114,36 @@ The receiver must satisfy this contract:
|
|
|
96
114
|
argument list — it is attacker-controlled, because it is whatever the remote
|
|
97
115
|
user typed;
|
|
98
116
|
3. split it on newlines into title / message / urgency / icon, each with a
|
|
99
|
-
default
|
|
117
|
+
default — **using only constructs bash 3.2 has**, not just bash 4+;
|
|
100
118
|
4. whitelist `urgency` against `low|normal|critical` and `icon` against the six
|
|
101
119
|
supported names, both of which become command-line flags;
|
|
102
120
|
5. resolve `DISPLAY` / `DBUS_SESSION_BUS_ADDRESS` for the session it is running
|
|
103
121
|
in, then `exec notify-send "$TITLE" "$MSG" --urgency=… --icon=…`.
|
|
104
122
|
|
|
123
|
+
Point 3 is not a detail. `mapfile`/`readarray` are bash 4.0+, and where they are
|
|
124
|
+
missing — macOS ships bash 3.2, as do some minimal Linux images — the array is
|
|
125
|
+
simply never built, every field falls back to its default, and the desktop gets
|
|
126
|
+
a notification titled "LaCode" with an empty body. No error is printed anywhere
|
|
127
|
+
that anyone will read, because the receiver's stderr goes to whoever happened to
|
|
128
|
+
open the ssh connection. A `while IFS= read -r` loop is equivalent and works
|
|
129
|
+
everywhere; `bin/oc-notify-receiver` uses one.
|
|
130
|
+
|
|
131
|
+
### This half of the hop is Linux
|
|
132
|
+
|
|
133
|
+
The receiver delivers through `notify-send`, and the forward that reaches it is
|
|
134
|
+
a systemd `--user` unit (below). Both are Linux, so **the desktop half of this
|
|
135
|
+
tunnel does not exist on macOS**, and that is by design rather than a gap.
|
|
136
|
+
|
|
137
|
+
On a Mac you do not need it: `lacode notify` run locally picks `osascript` (or
|
|
138
|
+
`terminal-notifier`) and shows the notification itself, with no tunnel, no
|
|
139
|
+
`authorized_keys` entry and no systemd unit involved. The tunnel exists for the
|
|
140
|
+
case opencode runs *somewhere else*, and a Mac is not going to be running it.
|
|
141
|
+
|
|
142
|
+
If you point the receiver at a machine with no `notify-send` anyway, it exits
|
|
143
|
+
non-zero and says so, naming `lacode notify` as the path that does work there.
|
|
144
|
+
`bin/oc-sync` refuses the same machine for the same reason, and names each
|
|
145
|
+
missing prerequisite instead of failing somewhere less obvious.
|
|
146
|
+
|
|
105
147
|
> **Note.** The published package ships the built CLI (`dist/lacode.js`, linked
|
|
106
148
|
> as `lacode`) and the tunnel behind it (`dist/tunnel.js`, importable as
|
|
107
149
|
> `@lawaty/lacode/tunnel`), not a receiver script. The receiver is the
|