@lawaty/lacode 0.1.1 → 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 +15 -38
- package/dist/agents.js +47 -240
- package/dist/commands.js +6 -6
- package/dist/index.js +145 -274
- package/dist/lacode.js +72 -15
- package/dist/pool.js +25 -2
- package/dist/tunnel.js +34 -13
- package/docs/CONTEXT.md +4 -4
- package/docs/TUNNEL.md +11 -2
- package/docs/USER-STORIES.md +16 -15
- package/package.json +1 -1
- 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"] });
|
|
@@ -120,19 +140,20 @@ function sendLocal(request, platform = process.platform) {
|
|
|
120
140
|
}
|
|
121
141
|
function sendRemote(request, tunnel) {
|
|
122
142
|
if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
);
|
|
136
157
|
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
|
|
137
158
|
}
|
|
138
159
|
function notify(request, env = process.env, platform = process.platform) {
|
|
@@ -158,6 +179,7 @@ function describe(env = process.env, platform = process.platform) {
|
|
|
158
179
|
return {
|
|
159
180
|
running: isRemote(env) ? "remote (over ssh)" : "local",
|
|
160
181
|
user: tunnel.user || "(unset)",
|
|
182
|
+
host: tunnel.host || "(unset)",
|
|
161
183
|
port: tunnel.port,
|
|
162
184
|
key: tunnel.key,
|
|
163
185
|
channel: localChannel(platform),
|
|
@@ -173,8 +195,8 @@ if (!process.features?.typescript) {
|
|
|
173
195
|
console.error("lacode: this node cannot load TypeScript sources; node >= 22.18 is required.");
|
|
174
196
|
process.exit(1);
|
|
175
197
|
}
|
|
176
|
-
const { spawnSync:
|
|
177
|
-
const result =
|
|
198
|
+
const { spawnSync: spawnSync3 } = await import("node:child_process");
|
|
199
|
+
const result = spawnSync3(
|
|
178
200
|
process.execPath,
|
|
179
201
|
["--experimental-strip-types", "--no-warnings", fileURLToPath(import.meta.url), ...process.argv.slice(2)],
|
|
180
202
|
{ stdio: "inherit", env: { ...process.env, LACODE_STRIP_TYPECHECKED: "1" } }
|
|
@@ -247,6 +269,41 @@ switch (command) {
|
|
|
247
269
|
process.exit(0);
|
|
248
270
|
break;
|
|
249
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}`);
|
|
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.");
|
|
304
|
+
process.exit(0);
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
250
307
|
default:
|
|
251
308
|
console.error(`lacode: unknown command ${JSON.stringify(safeTitle(command))}`);
|
|
252
309
|
console.error(USAGE);
|
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"] });
|
|
@@ -115,19 +133,20 @@ function sendLocal(request, platform = process.platform) {
|
|
|
115
133
|
}
|
|
116
134
|
function sendRemote(request, tunnel) {
|
|
117
135
|
if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
);
|
|
131
150
|
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
|
|
132
151
|
}
|
|
133
152
|
function notify(request, env = process.env, platform = process.platform) {
|
|
@@ -153,6 +172,7 @@ function describe(env = process.env, platform = process.platform) {
|
|
|
153
172
|
return {
|
|
154
173
|
running: isRemote(env) ? "remote (over ssh)" : "local",
|
|
155
174
|
user: tunnel.user || "(unset)",
|
|
175
|
+
host: tunnel.host || "(unset)",
|
|
156
176
|
port: tunnel.port,
|
|
157
177
|
key: tunnel.key,
|
|
158
178
|
channel: localChannel(platform),
|
|
@@ -172,6 +192,7 @@ export {
|
|
|
172
192
|
normalizeIcon,
|
|
173
193
|
normalizeUrgency,
|
|
174
194
|
notify,
|
|
195
|
+
parsePayload,
|
|
175
196
|
resolveTunnel,
|
|
176
197
|
safeMessage,
|
|
177
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
|
|
@@ -74,7 +82,8 @@ useful on a Mac rather than reporting only whether a Linux binary is installed.
|
|
|
74
82
|
|
|
75
83
|
| Variable | Default | Meaning |
|
|
76
84
|
| --- | --- | --- |
|
|
77
|
-
| `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. |
|
|
78
87
|
| `OC_TUNNEL_PORT` | `10022` | Reverse-forward listener port on the desktop. Give each remote host its own. |
|
|
79
88
|
| `OC_NOTIFY_KEY` | `~/.ssh/lacode_notify` | The dedicated key. Never a personal key. |
|
|
80
89
|
| `OC_CONNECT_TIMEOUT_MS` | `5000` | Ceiling on the hop. |
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -16,7 +16,7 @@ document; everything here is testable offline with a stub client and a
|
|
|
16
16
|
`mkdtempSync` state directory.
|
|
17
17
|
|
|
18
18
|
**Terminology.** A *slot* is one entry in `presets/free-tier.json`, carrying a
|
|
19
|
-
model and a `weight`. A *base* is a pooled agent type (`explore
|
|
19
|
+
model and a `weight`. A *base* is a pooled agent type (`explore`), and its slot
|
|
20
20
|
holders are `<base>-<slot>`, hidden in `opencode.jsonc`. A *claim* is a running
|
|
21
21
|
spawn recorded in `claims.<pid>.json`. A *context map* is the five-file
|
|
22
22
|
`.opencode/context/` codemap described in Epic G.
|
|
@@ -45,7 +45,7 @@ spawn recorded in `claims.<pid>.json`. A *context map* is the five-file
|
|
|
45
45
|
**Story ID(s) for tests:** US-1
|
|
46
46
|
**Acceptance criteria:**
|
|
47
47
|
- [ ] Given the root agent `build` with read/glob/grep denied, When a read-family tool is invoked, Then the call is denied and the agent is pointed at an exploration base rather than executing inline.
|
|
48
|
-
- [ ] Given a pooled base such as `explore
|
|
48
|
+
- [ ] Given a pooled base such as `explore` is spawned, When the plugin rewrites the spawn, Then the concrete model selected is a slot declared in `presets/free-tier.json`.
|
|
49
49
|
- [ ] Given a pool whose slots are all zero-cost, When N exploration spawns run, Then none of them is dispatched to a paid model.
|
|
50
50
|
|
|
51
51
|
### US-2: One knob selects the exploration/implementation model
|
|
@@ -125,9 +125,9 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
|
|
|
125
125
|
- [ ] Given a slot names a paid model, When the pool loads, Then the slot is accepted and routed to without a free-tier filter rejecting it.
|
|
126
126
|
- [ ] Given a model id contains a vendor prefix with slashes (e.g. `openrouter/nvidia/model:free`), When it is split into provider and model id, Then the split is on the first slash only and the id tail is preserved.
|
|
127
127
|
- [ ] Given more slots are declared than the pool allows, When the pool loads, Then the surplus entries are dropped with a warning naming the cap.
|
|
128
|
-
- [ ] Given a provider configured with **no** `whitelist` key, When the config hook binds a slot
|
|
129
|
-
- [ ] Given a provider the pool never names a slot
|
|
130
|
-
- [ ] Given a provider that already carries a `whitelist`, When the config hook binds a slot model
|
|
128
|
+
- [ ] Given a provider configured with **no** `whitelist` key, When the config hook binds a slot on that provider, Then no `whitelist` is written for it, because an absent whitelist already allows every model the provider offers.
|
|
129
|
+
- [ ] Given a provider the pool never names a slot on, When the config hook runs, Then that provider entry is left exactly as it was, with no `whitelist` key added.
|
|
130
|
+
- [ ] Given a provider that already carries a `whitelist`, When the config hook binds a slot model on it, Then the model id is appended and every entry the user wrote is preserved in order.
|
|
131
131
|
- [ ] Given any provider after the config hook has run, When its whitelist is inspected, Then it is either absent or non-empty, because an empty allowlist names no model at all and opencode deletes the provider outright.
|
|
132
132
|
|
|
133
133
|
`provider.<name>.whitelist` is an **allowlist**, not a to-do list: opencode
|
|
@@ -183,7 +183,8 @@ does not.
|
|
|
183
183
|
**Story ID(s) for tests:** US-11
|
|
184
184
|
**Acceptance criteria:**
|
|
185
185
|
- [ ] Given a session went busy and is now idle, When the idle event fires, Then a `done` notification is delivered.
|
|
186
|
-
- [ ] Given a session
|
|
186
|
+
- [ ] 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.
|
|
187
|
+
- [ ] 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.
|
|
187
188
|
- [ ] Given a session that never went busy, When it goes idle, Then no notification is sent.
|
|
188
189
|
- [ ] Given a subagent session or a cartographer session, When it goes idle, Then it is suppressed.
|
|
189
190
|
- [ ] Given the notify helper exits non-zero or writes to stderr, When the result is evaluated, Then the delivery is reported as failed rather than sent.
|
|
@@ -329,17 +330,17 @@ makes agents read it before they explore, then keeps it current on its own.
|
|
|
329
330
|
|
|
330
331
|
**Story ID(s) for tests:** US-19
|
|
331
332
|
**Acceptance criteria:**
|
|
332
|
-
- [ ] Given a config that defines none of LaCode's agents, When the config hook runs, Then a `context
|
|
333
|
-
- [ ] Given the injected agent set, When the slot variants are built, Then a hidden `context
|
|
333
|
+
- [ ] Given a config that defines none of LaCode's agents, When the config hook runs, Then a `context` subagent is injected, and it carries no model of its own, because the pool is the only thing that decides which model runs it.
|
|
334
|
+
- [ ] Given the injected agent set, When the slot variants are built, Then a hidden `context-<slot>` variant exists for every slot the pool can hand out, so a cartography run borrows shared capacity instead of a dedicated model.
|
|
334
335
|
- [ ] Given a user config with no `instructions` array, When the config hook runs, Then the standing context-map rule is injected, naming all five map files, telling the agent to check with `ls` and never the `glob` tool, to read only the file relevant to the task, and to start from the canonical entry points the map lists.
|
|
335
|
-
- [ ] Given the injected `context
|
|
336
|
+
- [ ] Given the injected `context`, When its prompt is inspected, Then it names the five files it owns, requires a short "Start here" list of canonical files per significant subsystem, and forbids file inventories — the map answers "what should I look at first?", never "tell me everything".
|
|
336
337
|
|
|
337
338
|
### US-20: The map stays current without being asked
|
|
338
339
|
**As a** user, **I want** the map refreshed automatically after a session that edited code, **so that** agents are never misled by stale documentation.
|
|
339
340
|
|
|
340
341
|
**Story ID(s) for tests:** US-20
|
|
341
342
|
**Acceptance criteria:**
|
|
342
|
-
- [ ] Given a file inside the project was edited, When the root session next goes idle, Then a `context
|
|
343
|
+
- [ ] Given a file inside the project was edited, When the root session next goes idle, Then a `context` session is created and prompted with the edited paths listed, rather than nothing happening.
|
|
343
344
|
- [ ] Given `.opencode/context/` does not exist, When the automatic run fires, Then it is told to bootstrap the map; given that the directory exists, Then it is told to make an incremental update.
|
|
344
345
|
- [ ] Given an idle event with no pending edit, or one from a subagent session, or one following an edit inside the map itself or inside a build directory, Then no cartographer is spawned.
|
|
345
346
|
- [ ] Given an automatic run borrowed a pool slot, When it completes, Then the slot is released and the cartographer session going idle does not spawn a second run.
|
|
@@ -351,15 +352,15 @@ makes agents read it before they explore, then keeps it current on its own.
|
|
|
351
352
|
|
|
352
353
|
**Story ID(s) for tests:** US-21
|
|
353
354
|
|
|
354
|
-
Enforcement is split deliberately, and the split matters: **what the permission table enforces** is that `context
|
|
355
|
+
Enforcement is split deliberately, and the split matters: **what the permission table enforces** is that `context` can write the map and that *no other agent can*; **what the prompt enforces** is that every other agent knows to report map-relevant findings instead of making them. The permission half is the load-bearing one — it holds regardless of what the model decides to do — but it cannot tell an agent what it *should* have done, so the instruction is not redundant and is not going away.
|
|
355
356
|
|
|
356
357
|
**Acceptance criteria:**
|
|
357
|
-
- [ ] Given the injected agent set, When every agent's `edit` permission is evaluated against a path inside `.opencode/context/`, Then `context
|
|
358
|
+
- [ ] Given the injected agent set, When every agent's `edit` permission is evaluated against a path inside `.opencode/context/`, Then `context` is the only agent whose winning rule for that path is `allow`, and every agent that may otherwise write files carries a `deny` on both spellings of the map path — so the map is closed to non-cartographers by permission, not only by instruction.
|
|
358
359
|
- [ ] Given an agent's `edit` rules are evaluated the way opencode evaluates them, When the narrow map `deny` is declared before a broad `"*": "allow"`, Then the broad rule wins and the agent is reported as a second writer; so the deny is required to be the LAST entry, and the shipped table must be declared in that order.
|
|
359
|
-
- [ ] Given the `context
|
|
360
|
+
- [ ] Given the `context` permission rule, When it is evaluated for a normally-rooted project and for a project rooted at `/`, Then both spellings of the map path are allowed, because opencode evaluates file permissions relative to the project root.
|
|
360
361
|
- [ ] Given a config whose one-writer rule allows neither spelling, When the writer rule is verified, Then it reports which rules it saw, that the map is not writable, and that both forms are required — instead of leaving a map that silently never updates.
|
|
361
362
|
- [ ] Given a config in which a non-cartographer agent would be granted the map, When the writer rule is verified, Then it reports that agent by name together with the rule that wins for it, and the report is silent once the deny is narrowed and placed last.
|
|
362
|
-
- [ ] Given an agent other than `context
|
|
363
|
+
- [ ] Given an agent other than `context` finishes work, When the standing rule reaches it, Then it is told never to edit the map itself and to report what belongs in it instead, so that a denied agent knows what to do rather than only that it was refused.
|
|
363
364
|
- [ ] Given `edit` is denied for the map, When the same agents read the map, Then reading is unaffected: the rule narrows the `edit` tool only, so the map stays readable by exactly the agents that are told to check it.
|
|
364
365
|
|
|
365
366
|
### US-22: On-demand map operations
|
|
@@ -367,7 +368,7 @@ Enforcement is split deliberately, and the split matters: **what the permission
|
|
|
367
368
|
|
|
368
369
|
**Story ID(s) for tests:** US-22
|
|
369
370
|
**Acceptance criteria:**
|
|
370
|
-
- [ ] Given a config that defines none of LaCode's commands, When the config hook runs, Then `/context-init`, `/context-update` and `/context-review` are injected, each targeting `context
|
|
371
|
+
- [ ] Given a config that defines none of LaCode's commands, When the config hook runs, Then `/context-init`, `/context-update` and `/context-review` are injected, each targeting `context` as a subtask.
|
|
371
372
|
- [ ] Given an injected command template, When opencode substitutes `$ARGUMENTS`, Then `/context-update` carries the user's focus text in the placeholder's place and the procedure around it is unchanged.
|
|
372
373
|
- [ ] Given the three commands have been injected, When their templates are inspected, Then each names the procedure it delegates to (bootstrap, incremental update, or review) and none carries an unsubstituted placeholder other than `/context-update`'s single `$ARGUMENTS`.
|
|
373
374
|
- [ ] Given the user's config already defines one of the three commands, When the config hook runs, Then that command is left byte-identical while the other two are still added.
|
package/package.json
CHANGED
package/presets/free-tier.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"plugin options -> ~/.config/lacode/pool.json -> this file -> a built-in fallback.",
|
|
5
5
|
"",
|
|
6
6
|
"src/lib/pool.ts reads it at config time and rewrites every pooled variant agent",
|
|
7
|
-
"(explore-
|
|
7
|
+
"(explore-N, implement-N, context-N) plus the provider whitelists",
|
|
8
8
|
"to match, so no model is written down next to an agent anywhere.",
|
|
9
9
|
"Slot N is the same model for every base agent, which is what makes load shared.",
|
|
10
10
|
"",
|