@lawaty/lacode 0.10.0 → 0.11.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 +174 -1
- package/README.md +20 -13
- package/dist/commands.js +14 -3
- package/dist/index.js +906 -383
- package/dist/lacode.js +211 -48
- package/dist/pool.js +41 -6
- package/dist/tunnel.js +185 -13
- package/docs/CONFIGURATION.md +21 -7
- package/docs/OMNIROUTE.md +1 -1
- package/docs/USER-STORIES.md +85 -6
- package/package.json +1 -1
- package/presets/README.md +4 -3
- package/presets/free-tier.json +3 -3
package/dist/tunnel.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
// src/tunnel/index.ts
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "node:fs";
|
|
4
|
+
import { homedir as homedir3 } from "node:os";
|
|
5
|
+
import { join as join4 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/lib/notify.ts
|
|
8
|
+
import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, statSync as statSync2, unlinkSync as unlinkSync2, writeSync } from "node:fs";
|
|
9
|
+
import { homedir as homedir2 } from "node:os";
|
|
10
|
+
import { join as join3 } from "node:path";
|
|
11
|
+
|
|
12
|
+
// src/lib/pool.ts
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
14
|
import { homedir } from "node:os";
|
|
5
15
|
import { join as join2 } from "node:path";
|
|
6
16
|
|
|
@@ -24,6 +34,36 @@ var findPackageRoot = (start) => {
|
|
|
24
34
|
};
|
|
25
35
|
var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
26
36
|
|
|
37
|
+
// src/lib/pool.ts
|
|
38
|
+
var CLAIM_TTL_MS = 10 * 60 * 1e3;
|
|
39
|
+
var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
|
|
40
|
+
var ORPHAN_TMP_MS = 60 * 1e3;
|
|
41
|
+
var CATALOG_TTL_MS = 10 * 60 * 1e3;
|
|
42
|
+
var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
|
|
43
|
+
var STUCK_IDLE_MS = 3 * 60 * 1e3;
|
|
44
|
+
var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
|
|
45
|
+
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
46
|
+
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
47
|
+
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
48
|
+
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
49
|
+
var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
|
|
50
|
+
var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
|
|
51
|
+
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
52
|
+
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
53
|
+
var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
|
|
54
|
+
var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
|
|
55
|
+
var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
|
|
56
|
+
var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
|
|
57
|
+
var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
|
|
58
|
+
var CHILD_MEMORY_MS = 30 * 6e4;
|
|
59
|
+
|
|
60
|
+
// src/lib/notify.ts
|
|
61
|
+
var NOTIFY_SESSION_ENV = "LACODE_NOTIFY_SESSION";
|
|
62
|
+
var NOTIFY_CLOSE_ENV = "LACODE_NOTIFY_CLOSE";
|
|
63
|
+
var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join3(homedir2(), ".local", "share", "opencode", "lacode", "notify");
|
|
64
|
+
var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
|
|
65
|
+
var notificationIdFile = (correlationKey) => join3(notificationStateDir(), `${safeCorrelationKey(correlationKey)}.ids`);
|
|
66
|
+
|
|
27
67
|
// src/tunnel/index.ts
|
|
28
68
|
var ICONS = [
|
|
29
69
|
"utilities-terminal",
|
|
@@ -46,7 +86,7 @@ function resolveTunnel(env = process.env) {
|
|
|
46
86
|
user: env.OC_LOCAL_USER ?? "",
|
|
47
87
|
host: env.OC_LOCAL_HOST ?? "",
|
|
48
88
|
port: Number.isFinite(port) && port > 0 ? port : 10022,
|
|
49
|
-
key: env.OC_NOTIFY_KEY ??
|
|
89
|
+
key: env.OC_NOTIFY_KEY ?? join4(homedir3(), ".ssh", "lacode_notify"),
|
|
50
90
|
connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
|
|
51
91
|
};
|
|
52
92
|
}
|
|
@@ -74,16 +114,32 @@ var safeRequestId = (raw) => {
|
|
|
74
114
|
if (!clean) return void 0;
|
|
75
115
|
return clean.length > 128 ? clean.slice(0, 128) : clean;
|
|
76
116
|
};
|
|
117
|
+
var CLOSE_REQUEST_PREFIX = "close:";
|
|
118
|
+
var wireCorrelationKey = (raw) => safeText(raw ?? "", "", 200);
|
|
119
|
+
var presentCorrelationKey = (raw) => {
|
|
120
|
+
const clean = wireCorrelationKey(raw);
|
|
121
|
+
return clean ? clean : void 0;
|
|
122
|
+
};
|
|
123
|
+
var isCloseRequest = (correlationKey) => typeof correlationKey === "string" && correlationKey.startsWith(CLOSE_REQUEST_PREFIX);
|
|
124
|
+
var closeRequestTarget = (correlationKey) => {
|
|
125
|
+
if (!isCloseRequest(correlationKey)) return void 0;
|
|
126
|
+
const target = correlationKey?.slice(CLOSE_REQUEST_PREFIX.length).trim() ?? "";
|
|
127
|
+
return target ? target : void 0;
|
|
128
|
+
};
|
|
77
129
|
var FIELD_SEPARATOR = "\n";
|
|
78
130
|
function parsePayload(raw) {
|
|
79
|
-
const [title, message, urgency, icon, sound, requestID] = raw.split(FIELD_SEPARATOR);
|
|
131
|
+
const [title, message, urgency, icon, sound, requestID, correlationKey] = raw.split(FIELD_SEPARATOR);
|
|
132
|
+
const key = wireCorrelationKey(correlationKey);
|
|
80
133
|
return {
|
|
81
134
|
title: safeTitle(title),
|
|
82
135
|
message: safeMessage(message),
|
|
83
136
|
urgency: normalizeUrgency(urgency),
|
|
84
137
|
icon: normalizeIcon(icon),
|
|
85
138
|
sound: normalizeSound(sound),
|
|
86
|
-
requestID: safeRequestId(requestID)
|
|
139
|
+
requestID: safeRequestId(requestID),
|
|
140
|
+
// The seventh field, empty when there is none. A close request keeps its
|
|
141
|
+
// marker here, so the receiver can still tell it apart from a session.
|
|
142
|
+
...key ? { correlationKey: key } : {}
|
|
87
143
|
};
|
|
88
144
|
}
|
|
89
145
|
var encodePayload = (request) => [
|
|
@@ -94,7 +150,11 @@ var encodePayload = (request) => [
|
|
|
94
150
|
normalizeSound(request.sound),
|
|
95
151
|
// The sixth field, empty when there is no request id: an absent id and an
|
|
96
152
|
// empty one mean the same thing, and the field count never changes.
|
|
97
|
-
safeRequestId(request.requestID) ?? ""
|
|
153
|
+
safeRequestId(request.requestID) ?? "",
|
|
154
|
+
// The seventh field, the correlation key, empty when there is none. The
|
|
155
|
+
// `close:` marker of a close request is part of the key and travels with
|
|
156
|
+
// it, so the far end closes instead of raising.
|
|
157
|
+
wireCorrelationKey(request.correlationKey)
|
|
98
158
|
].join(FIELD_SEPARATOR);
|
|
99
159
|
var SSH_OPTS = [
|
|
100
160
|
"-o",
|
|
@@ -114,11 +174,16 @@ var CONTROL_OPTS = [
|
|
|
114
174
|
"-o",
|
|
115
175
|
"ControlPersist=300",
|
|
116
176
|
"-o",
|
|
117
|
-
`ControlPath=${
|
|
177
|
+
`ControlPath=${join4(homedir3(), ".ssh", "lacode-cm-%C")}`
|
|
118
178
|
];
|
|
119
|
-
var run = (command, args, timeout) => {
|
|
179
|
+
var run = (command, args, timeout, extraEnv) => {
|
|
120
180
|
try {
|
|
121
|
-
const result = spawnSync(command, args, {
|
|
181
|
+
const result = spawnSync(command, args, {
|
|
182
|
+
timeout,
|
|
183
|
+
encoding: "utf8",
|
|
184
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
185
|
+
...extraEnv ? { env: { ...process.env, ...extraEnv } } : {}
|
|
186
|
+
});
|
|
122
187
|
return {
|
|
123
188
|
ok: result.status === 0,
|
|
124
189
|
stdout: result.stdout ?? "",
|
|
@@ -178,20 +243,104 @@ var NO_CHANNEL = {
|
|
|
178
243
|
darwin: "neither osascript nor terminal-notifier is installed"
|
|
179
244
|
};
|
|
180
245
|
var NEVER_EXPIRE_FLAG = "--expire-time=0";
|
|
246
|
+
var APP_NAME_FLAG = "--app-name=LaCode";
|
|
181
247
|
var NOTIFICATION_ACTIONS = [
|
|
182
248
|
["once", "Allow once"],
|
|
183
249
|
["always", "Always allow"],
|
|
184
250
|
["reject", "Deny"]
|
|
185
251
|
];
|
|
186
252
|
var ACTION_WAIT_MS = 10 * 6e4;
|
|
253
|
+
var isNotificationId = (id) => /^\d+$/.test(id);
|
|
254
|
+
var recordNotificationId = (correlationKey, id) => {
|
|
255
|
+
if (!isNotificationId(id)) return false;
|
|
256
|
+
try {
|
|
257
|
+
mkdirSync3(notificationStateDir(), { recursive: true });
|
|
258
|
+
appendFileSync(notificationIdFile(correlationKey), `${id}
|
|
259
|
+
`, "utf8");
|
|
260
|
+
return true;
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
var sessionBusEnv = () => {
|
|
266
|
+
if (process.env.DBUS_SESSION_BUS_ADDRESS) return {};
|
|
267
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : void 0;
|
|
268
|
+
return uid === void 0 ? {} : { DBUS_SESSION_BUS_ADDRESS: `unix:path=/run/user/${uid}/bus` };
|
|
269
|
+
};
|
|
270
|
+
var CLOSE_TIMEOUT_MS = 5e3;
|
|
271
|
+
function closeNotification(id) {
|
|
272
|
+
if (!isNotificationId(id)) return false;
|
|
273
|
+
const env = sessionBusEnv();
|
|
274
|
+
const viaGdbus = run(
|
|
275
|
+
"gdbus",
|
|
276
|
+
[
|
|
277
|
+
"call",
|
|
278
|
+
"--session",
|
|
279
|
+
"--dest",
|
|
280
|
+
"org.freedesktop.Notifications",
|
|
281
|
+
"--object-path",
|
|
282
|
+
"/org/freedesktop/Notifications",
|
|
283
|
+
"--method",
|
|
284
|
+
"org.freedesktop.Notifications.CloseNotification",
|
|
285
|
+
id
|
|
286
|
+
],
|
|
287
|
+
CLOSE_TIMEOUT_MS,
|
|
288
|
+
env
|
|
289
|
+
);
|
|
290
|
+
if (viaGdbus.ok) return true;
|
|
291
|
+
const viaDbusSend = run(
|
|
292
|
+
"dbus-send",
|
|
293
|
+
[
|
|
294
|
+
"--session",
|
|
295
|
+
"--dest=org.freedesktop.Notifications",
|
|
296
|
+
"--type=method_call",
|
|
297
|
+
"/org/freedesktop/Notifications",
|
|
298
|
+
"org.freedesktop.Notifications.CloseNotification",
|
|
299
|
+
`uint32:${id}`
|
|
300
|
+
],
|
|
301
|
+
CLOSE_TIMEOUT_MS,
|
|
302
|
+
env
|
|
303
|
+
);
|
|
304
|
+
return viaDbusSend.ok;
|
|
305
|
+
}
|
|
306
|
+
function closeCorrelationKey(correlationKey) {
|
|
307
|
+
const file = notificationIdFile(correlationKey);
|
|
308
|
+
let content = "";
|
|
309
|
+
try {
|
|
310
|
+
content = readFileSync4(file, "utf8");
|
|
311
|
+
} catch {
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
let closed = true;
|
|
315
|
+
for (const line of content.split("\n")) {
|
|
316
|
+
const id = line.trim();
|
|
317
|
+
if (!isNotificationId(id)) continue;
|
|
318
|
+
if (!closeNotification(id)) closed = false;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
unlinkSync3(file);
|
|
322
|
+
} catch {
|
|
323
|
+
}
|
|
324
|
+
return closed;
|
|
325
|
+
}
|
|
326
|
+
var splitNotifyOutput = (stdout) => {
|
|
327
|
+
const newline = stdout.indexOf("\n");
|
|
328
|
+
if (newline === -1) return { id: stdout.trim(), action: "" };
|
|
329
|
+
return { id: stdout.slice(0, newline).trim(), action: stdout.slice(newline + 1).trim() };
|
|
330
|
+
};
|
|
187
331
|
function sendLocal(request, platform = process.platform) {
|
|
332
|
+
const closing = closeRequestTarget(request.correlationKey);
|
|
333
|
+
if (closing !== void 0) {
|
|
334
|
+
const closed = closeCorrelationKey(closing);
|
|
335
|
+
return { ok: closed, detail: closed ? void 0 : "a popup could not be closed", transport: "local" };
|
|
336
|
+
}
|
|
188
337
|
const sound = normalizeSound(request.sound);
|
|
189
338
|
const playSound = () => {
|
|
190
339
|
if (sound === "none") return;
|
|
191
340
|
const channel2 = soundChannel(platform);
|
|
192
341
|
if (channel2 === "none") return;
|
|
193
|
-
const path =
|
|
194
|
-
if (!
|
|
342
|
+
const path = join4(PACKAGE_ROOT, "sounds", `${sound}.wav`);
|
|
343
|
+
if (!existsSync2(path)) return;
|
|
195
344
|
const child = spawn(channel2, [path], { detached: true, stdio: "ignore" });
|
|
196
345
|
child.on("error", () => {
|
|
197
346
|
});
|
|
@@ -207,13 +356,17 @@ function sendLocal(request, platform = process.platform) {
|
|
|
207
356
|
const requestID = safeRequestId(request.requestID);
|
|
208
357
|
const actionable = channel === "notify-send" && requestID !== void 0;
|
|
209
358
|
const actionArgs = actionable ? NOTIFICATION_ACTIONS.map(([name, label]) => `--action=${name}=${label}`) : [];
|
|
359
|
+
const correlationKey = presentCorrelationKey(request.correlationKey);
|
|
360
|
+
const trackable = channel === "notify-send" && correlationKey !== void 0;
|
|
210
361
|
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)] : (
|
|
211
362
|
// 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]
|
|
363
|
+
[title, body, APP_NAME_FLAG, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`, NEVER_EXPIRE_FLAG, ...trackable ? ["--print-id"] : [], ...actionArgs]
|
|
213
364
|
);
|
|
214
365
|
const result = run(CHANNEL_BINARY[channel], args, actionable ? ACTION_WAIT_MS : 5e3);
|
|
215
366
|
playSound();
|
|
216
|
-
const
|
|
367
|
+
const printed = trackable ? splitNotifyOutput(result.stdout) : { id: "", action: result.stdout.trim() };
|
|
368
|
+
const action = actionable ? printed.action : "";
|
|
369
|
+
if (correlationKey !== void 0 && printed.id) recordNotificationId(correlationKey, printed.id);
|
|
217
370
|
return {
|
|
218
371
|
ok: result.ok,
|
|
219
372
|
detail: result.ok ? void 0 : result.stderr || result.detail,
|
|
@@ -262,7 +415,17 @@ function sendRemote(request, tunnel) {
|
|
|
262
415
|
}
|
|
263
416
|
function notify(request, env = process.env, platform = process.platform) {
|
|
264
417
|
try {
|
|
265
|
-
const
|
|
418
|
+
const closeKey = (env[NOTIFY_CLOSE_ENV] ?? "").trim();
|
|
419
|
+
if (closeKey) {
|
|
420
|
+
if (!isRemote(env)) {
|
|
421
|
+
const closed = closeCorrelationKey(closeKey);
|
|
422
|
+
return { ok: closed, detail: closed ? void 0 : "a popup could not be closed", transport: "local" };
|
|
423
|
+
}
|
|
424
|
+
return sendRemote({ ...request, correlationKey: `${CLOSE_REQUEST_PREFIX}${closeKey}` }, resolveTunnel(env));
|
|
425
|
+
}
|
|
426
|
+
const sessionKey = presentCorrelationKey(env[NOTIFY_SESSION_ENV]);
|
|
427
|
+
const tracked = sessionKey ? { ...request, correlationKey: request.correlationKey ?? sessionKey } : request;
|
|
428
|
+
const result = isRemote(env) ? sendRemote(tracked, resolveTunnel(env)) : sendLocal(tracked, platform);
|
|
266
429
|
return result.action ? { ...result, action: result.action } : result;
|
|
267
430
|
} catch (error) {
|
|
268
431
|
return { ok: false, detail: String(error), transport: "none" };
|
|
@@ -294,21 +457,30 @@ function describe(env = process.env, platform = process.platform) {
|
|
|
294
457
|
};
|
|
295
458
|
}
|
|
296
459
|
export {
|
|
460
|
+
CLOSE_REQUEST_PREFIX,
|
|
297
461
|
FIELD_SEPARATOR,
|
|
298
462
|
ICONS,
|
|
299
463
|
NOTIFICATION_ACTIONS,
|
|
300
464
|
SOUNDS,
|
|
465
|
+
closeCorrelationKey,
|
|
466
|
+
closeNotification,
|
|
467
|
+
closeRequestTarget,
|
|
301
468
|
describe,
|
|
302
469
|
encodePayload,
|
|
303
470
|
forward,
|
|
471
|
+
isCloseRequest,
|
|
472
|
+
isNotificationId,
|
|
304
473
|
isRemote,
|
|
305
474
|
localChannel,
|
|
306
475
|
normalizeIcon,
|
|
307
476
|
normalizeSound,
|
|
308
477
|
normalizeUrgency,
|
|
478
|
+
notificationIdFile,
|
|
479
|
+
notificationStateDir,
|
|
309
480
|
notify,
|
|
310
481
|
parsePayload,
|
|
311
482
|
resolveTunnel,
|
|
483
|
+
safeCorrelationKey,
|
|
312
484
|
safeMessage,
|
|
313
485
|
safeRequestId,
|
|
314
486
|
safeTitle,
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -7,13 +7,16 @@ The plugin options, where the model pool gets its slots, and exactly what LaCode
|
|
|
7
7
|
The slot list is the first of these that yields usable slots:
|
|
8
8
|
|
|
9
9
|
1. plugin options — `models` in the [tuple](../README.md#configure)
|
|
10
|
-
2. `~/.config/lacode/pool.
|
|
11
|
-
3.
|
|
12
|
-
4.
|
|
10
|
+
2. `~/.config/lacode/pool.txt` — flat `model [weight]` per line, preferred
|
|
11
|
+
3. `~/.config/lacode/pool.json` (its parent directory is created for you)
|
|
12
|
+
4. the bundled `presets/free-tier.json`
|
|
13
|
+
5. a built-in fallback, so routing always works
|
|
13
14
|
|
|
14
15
|
A missing, unreadable or structurally broken source falls through to the next and
|
|
15
|
-
logs one warning; nothing here throws into opencode.
|
|
16
|
-
change
|
|
16
|
+
logs one warning; nothing here throws into opencode. Sources 2–4 are watched: a
|
|
17
|
+
change reloads the slot table, re-binds the variant agents and validates every
|
|
18
|
+
slot (one real request per model) without a restart. Only a change to the
|
|
19
|
+
plugin's own code needs one.
|
|
17
20
|
|
|
18
21
|
**Any model list is accepted, including paid ones** (US-7). There is no free-tier
|
|
19
22
|
allowlist anywhere in the package, and no cost check of any kind: only the
|
|
@@ -69,8 +72,19 @@ Note that you cannot *spawn* a variant by name: the pool refuses
|
|
|
69
72
|
`explore-1` and friends and points you at the bare base, so per-variant
|
|
70
73
|
permissions only ever apply to a spawn the plugin routed itself.
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
The pool files are the exception to the restart rule: editing `pool.txt`,
|
|
76
|
+
`pool.json` or the bundled preset is picked up live — the table reloads, the
|
|
77
|
+
variant agents re-bind and every slot is validated. A change to `opencode.jsonc`
|
|
78
|
+
itself, or to the plugin's code, still needs a restart.
|
|
79
|
+
|
|
80
|
+
**The delegation path matters.** The pool routes `task` tool calls whose
|
|
81
|
+
`subagent_type` is `explore`, `implement` or `context`. A spawn with no
|
|
82
|
+
`subagent_type`, an `@explore`/`@implement` mention, or a direct session prompt
|
|
83
|
+
naming a base agent does not pass through that hook: it runs opencode's
|
|
84
|
+
default/subagent path and inherits the caller's model. Spawn the bare base
|
|
85
|
+
through the task tool (`subagent_type: "explore"`), not by mention or variant
|
|
86
|
+
name. The plugin logs a warning for the no-`subagent_type` case; the
|
|
87
|
+
mention/direct-session case cannot be detected before it runs.
|
|
74
88
|
|
|
75
89
|
|
|
76
90
|
## What gets injected
|
package/docs/OMNIROUTE.md
CHANGED
|
@@ -104,7 +104,7 @@ work (exploration, implementation, cartography) through the gateway, add an
|
|
|
104
104
|
# ~/.config/lacode/pool.txt
|
|
105
105
|
omniroute/free-mix
|
|
106
106
|
opencode/big-pickle
|
|
107
|
-
opencode-go/
|
|
107
|
+
opencode-go/beam-free
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
OmniRoute is a provider like any other: the pool decides its place and weight,
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -115,7 +115,7 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
|
|
|
115
115
|
- [ ] Given claims are held by several processes, When the status view is requested, Then it shows every process's claims and the aggregate load per slot.
|
|
116
116
|
- [ ] Given a model is cooling with a stated reason, When the status view is requested, Then the model is listed as cooling together with that reason.
|
|
117
117
|
- [ ] Given a claim file is older than the dead-file threshold, When the status view is computed, Then the stale claim is pruned rather than counted.
|
|
118
|
-
- [ ] Given a slot is at its ceiling, When the status view is requested, Then it is not marked as the slot the next spawn would take, because the rotation's turns are skipped for a full slot until it frees a claim.
|
|
118
|
+
- [ ] Given a slot is at its ceiling, When the status view is requested, Then it is not marked as the slot the next spawn would take, because the rotation's turns are skipped for a full slot until it frees a claim — unless no other slot is available and under its ceiling, in which case that full slot is the next one.
|
|
119
119
|
|
|
120
120
|
### US-7: Non-free models still work
|
|
121
121
|
**As a** user who wants a paid model in the pool, **I want** to supply any model list, **so that** the pool is not restricted to free tiers.
|
|
@@ -151,6 +151,23 @@ does not.
|
|
|
151
151
|
- [ ] Given OmniRoute is disabled, enabled with an invalid block, unhealthy, or returns no usable models, When the config hook runs, Then no provider is registered, no pool slot changes, and a failure is warned about once.
|
|
152
152
|
- [ ] Given the gateway is unreachable, When pooled work runs, Then the only thing affected is a slot that names `omniroute/<model>`; every other slot keeps working, because the plugin never put an `omniroute` slot in the pool.
|
|
153
153
|
|
|
154
|
+
### US-31: A limit that hangs is killed, and the retry lands on a slot that answers
|
|
155
|
+
**As a** user whose provider has run out of allowance, **I want** the stalled task killed and the retry sent to a slot that is not on cooldown, **so that** one exhausted provider does not strand the session.
|
|
156
|
+
|
|
157
|
+
A limit does not always arrive as an error. Sometimes the provider accepts the request and then says nothing, and opencode keeps retrying the refused model — so the task is neither done nor failed, and the session waits on a provider that has already said no. The plugin has to end that wait itself: abort the stalled session, cool the model that was refused, and let the retry pick a slot that answers. When the provider names itself, the whole provider is benched; when it does not, only the model the session was running on is cooled — that is the one the refusal is evidence about.
|
|
158
|
+
|
|
159
|
+
**Story ID(s) for tests:** US-31
|
|
160
|
+
**Acceptance criteria:**
|
|
161
|
+
- [ ] Given a pooled session's provider reports a limit in a retry status that names no provider, When the status is handled, Then the model that session is running on is cooled and a cooldown file is written for it.
|
|
162
|
+
- [ ] Given a pooled session reports a limit and then goes silent, When the limit is handled, Then that session is aborted and its claim is released, so the slot is not held by a task that will never answer.
|
|
163
|
+
- [ ] Given the refused model is cooling, When the caller retries the spawn, Then the retry is routed to a different slot whose model is not on cooldown.
|
|
164
|
+
- [ ] Given a limit names its provider, When the limit is handled, Then every slot on that provider is cooled and a slot on another provider is not.
|
|
165
|
+
- [ ] Given a limit-shaped error whose text is exactly `limit reached` and which carries no status code, When the error is matched, Then the model is cooled and the in-flight session is aborted.
|
|
166
|
+
- [ ] Given an error whose text is `context limit reached`, When the error is matched, Then nothing is cooled and nothing is aborted, because a prompt that is too long is not a provider quota.
|
|
167
|
+
- [ ] Given a session stalls with no limit signal at all, When the reaper sweeps, Then the session is aborted, its model cooled, and the next spawn routes to a slot that is not on cooldown.
|
|
168
|
+
- [ ] Given a provider reports a transient upstream outage rather than a limit, When the status is handled, Then the model is cooled for one short flat window instead of the quota window, and the session is still aborted, because an outage lifts on its own and must not bench a healthy model.
|
|
169
|
+
- [ ] Given a transient outage was cooled, When the same model later fails with a real limit, Then the cooldown starts at the first strike rather than an escalated one, because an outage lifts on its own and must not make the next quota window longer.
|
|
170
|
+
|
|
154
171
|
---
|
|
155
172
|
|
|
156
173
|
## Epic C — Hang detection and auto-recovery
|
|
@@ -240,6 +257,22 @@ does not.
|
|
|
240
257
|
- [ ] Given the plugin option `sound` is `false`, or `LACODE_SOUND` is `0`, `false`, `off` or `no`, When a notification is delivered, Then its sound is `none`; when both are set the environment wins over the option, and any other value — a typo included — leaves sound on, because a misspelt variable must not be how you get silence.
|
|
241
258
|
- [ ] Given a sound name that is not one of `none`, `done`, `question`, `permission` or `error`, When it arrives, Then it is treated as `none` and never becomes a path or a command, because the hop carries data only (see US-18).
|
|
242
259
|
|
|
260
|
+
### US-30: A notification fades once you are working in that chat
|
|
261
|
+
**As a** user with several sessions open, **I want** the popup of the session I am working in to close itself, **so that** a notice I have already dealt with stops sitting on my screen waiting for an X I was never going to press.
|
|
262
|
+
|
|
263
|
+
A popup the user is already looking past has said everything it is going to say: once they answer the permission, answer the question, or type into that session's chat, the answer is on their screen already. So the popup fades on its own — but never sooner than five seconds after it was raised, because a notice nobody could read is no notice, and a popup nobody touched still waits for them, exactly as before.
|
|
264
|
+
|
|
265
|
+
The popup belongs to its session: working in one chat closes that chat's popup and no other. The close is the **helper's** job, never the plugin's, because the popup lives on the desktop and only the desktop knows the notification id the daemon gave it. One code path, two topologies: the helper closes locally, or over the hop where the popup was actually raised.
|
|
266
|
+
|
|
267
|
+
**Story ID(s) for tests:** US-30
|
|
268
|
+
**Acceptance criteria:**
|
|
269
|
+
- [ ] Given a notification has been delivered, When the user answers a permission, answers a question, or types into that session's chat, Then that session's notification closes by itself, without the user clicking the popup's close button.
|
|
270
|
+
- [ ] Given a notification was delivered less than five seconds ago, When the user works in that chat, Then the notification stays until five seconds have passed, because a notice nobody could read is no notice.
|
|
271
|
+
- [ ] Given the user works in one session's chat, When another session still has a popup up, Then that popup stays: a notification belongs to its session, and only that session's user is looking at it.
|
|
272
|
+
- [ ] Given the user never works in that chat, When the notification was delivered, Then it stays until the user closes it, exactly as before.
|
|
273
|
+
- [ ] Given a permission popup is closed this way, When the permission was already answered in the terminal, Then nothing is answered twice: the close takes the popup down and answers nothing.
|
|
274
|
+
- [ ] Given the notification was delivered by a session on a remote host and reached the desktop over the tunnel, When the user works in that chat, Then the popup on the desktop closes too, because the popup lives on the desktop and only the desktop knows its notification id.
|
|
275
|
+
|
|
243
276
|
---
|
|
244
277
|
|
|
245
278
|
## Epic E — Remote hosts over SSH
|
|
@@ -380,7 +413,7 @@ agents read it before they explore, then keeps it current on its own.
|
|
|
380
413
|
**Acceptance criteria:**
|
|
381
414
|
- [ ] 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.
|
|
382
415
|
- [ ] 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.
|
|
383
|
-
- [ ] Given
|
|
416
|
+
- [ ] Given the shipped agent prompts and the context agent, When they are inspected, Then they carry the standing context-map rule — naming all five map files, checking with `ls` and never the `glob` tool, reading only the file relevant to the task, and starting from the canonical entry points the map lists — because that rule now lives in the repo-level `AGENTS.md` rather than being injected into the user's `instructions` array.
|
|
384
417
|
- [ ] 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".
|
|
385
418
|
|
|
386
419
|
### US-20: The map stays current without being asked
|
|
@@ -424,10 +457,49 @@ Enforcement is split deliberately, and the split matters: **what the permission
|
|
|
424
457
|
|
|
425
458
|
---
|
|
426
459
|
|
|
460
|
+
## Epic H — Retention (sweeps)
|
|
461
|
+
|
|
462
|
+
### US-32: Retire old sessions
|
|
463
|
+
**As an** opencode user whose session list only ever grows, **I want** a sweep that retires sessions past a retention window together with everything that belongs to them, **so that** the database stays usable and I keep a compact record of what went.
|
|
464
|
+
|
|
465
|
+
**Story ID(s) for tests:** US-32
|
|
466
|
+
**Acceptance criteria:**
|
|
467
|
+
- [ ] Given sessions whose `time_created` is older than `--days` (default 30) and whose `time_updated` is older than `--grace-hours` (default 24), When the sweep runs, Then those sessions are retired together with their messages, parts, todos, shares, inputs, context epochs and event rows.
|
|
468
|
+
- [ ] Given a session that is old but was touched inside the grace window, When the sweep runs, Then it is left alone, because the grace window is a stop, not a speed.
|
|
469
|
+
- [ ] Given an old parent session with a child, When the sweep runs, Then the child is retired together with the parent, so no orphan remains.
|
|
470
|
+
- [ ] Given `--directory D`, When the sweep runs, Then only sessions rooted in that directory are selected, and a child in another directory goes with its parent.
|
|
471
|
+
- [ ] Given `--limit N`, When the sweep runs, Then at most N top-level sessions (with their descendants) are retired.
|
|
472
|
+
- [ ] Given `--dry-run`, When the sweep runs, Then it reports what would go and changes nothing on disk.
|
|
473
|
+
- [ ] Given `--no-events`, When the sweep runs, Then the event rows of the retired sessions are kept.
|
|
474
|
+
- [ ] Given `--vacuum`, When the sweep runs, Then the database is rewritten afterwards so the freed pages go back to the filesystem.
|
|
475
|
+
- [ ] Given sessions are retired, When the sweep completes, Then a compact record (id, title, directory, project, created, updated) is written under the session archive, which is the recovery path.
|
|
476
|
+
|
|
477
|
+
### US-33: Prune the event log
|
|
478
|
+
**As an** opencode user whose `event` table grows without bound, **I want** a sweep that deletes event rows older than a window except those belonging to protected sessions, **so that** the database stops growing while the transcript and sync stay intact.
|
|
479
|
+
|
|
480
|
+
**Story ID(s) for tests:** US-33
|
|
481
|
+
**Acceptance criteria:**
|
|
482
|
+
- [ ] Given event rows whose id is older than `--days` (default 7), When the sweep runs, Then those rows are candidates and newer rows are not, because the id carries the row's creation time.
|
|
483
|
+
- [ ] Given a session that takes part in session sync (`event_sequence.owner_id` set), When the sweep runs, Then none of its event rows are pruned.
|
|
484
|
+
- [ ] Given a session that belongs to a workspace, When the sweep runs, Then none of its event rows are pruned.
|
|
485
|
+
- [ ] Given a session touched inside the grace window (`--grace-hours`, default 1), When the sweep runs, Then all of its event rows are kept.
|
|
486
|
+
- [ ] Given an event whose session row is gone, When the sweep runs, Then that event is pruned, because it is garbage.
|
|
487
|
+
- [ ] Given `--dry-run`, When the sweep runs, Then it reports the counts and deletes nothing, and still writes one JSON line to the run log.
|
|
488
|
+
- [ ] Given `--limit N`, When the sweep runs, Then at most N rows are deleted, oldest first, and the remainder is reported for a later run.
|
|
489
|
+
- [ ] Given a window longer than the id era, When the sweep runs, Then the window is clamped to the era and the table is not wiped, and the operator is warned.
|
|
490
|
+
- [ ] Given candidate rows, When they are deleted, Then deletion happens in chunks and a failure mid-run leaves the rest for a later run.
|
|
491
|
+
- [ ] Given `--vacuum` (and not `--dry-run`), When the sweep runs, Then the database is rewritten afterwards.
|
|
492
|
+
- [ ] Given the sweep runs, When it completes, Then the `event_sequence` and `session` tables are never changed.
|
|
493
|
+
- [ ] Given a run, When it completes, Then one JSON line is appended to `~/.local/share/opencode/lacode/event-prune.jsonl` carrying ts, days, graceHours, cutoff, rows, bytes, aggregates, dryRun and elapsedMs.
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
427
497
|
## Coverage
|
|
428
498
|
|
|
429
|
-
Every story below has at least one behavioural test
|
|
430
|
-
`tests/stories
|
|
499
|
+
Every story below has at least one behavioural test. The story suites live in
|
|
500
|
+
`tests/stories/`, and the root-level suites `tests/*-test.mjs` are scanned too
|
|
501
|
+
(US-32, US-33), so `tests/stories/coverage.test.mjs` enforces coverage in both
|
|
502
|
+
directions:
|
|
431
503
|
it fails if a story here has no test, and it fails if a test names a story this
|
|
432
504
|
document does not define. The acceptance criteria above are unchanged; only this
|
|
433
505
|
table was filled in.
|
|
@@ -443,6 +515,7 @@ table was filled in.
|
|
|
443
515
|
| US-6 | B | `tests/stories/b-provider-resilience.test.mjs` |
|
|
444
516
|
| US-7 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-models-test.mjs` |
|
|
445
517
|
| US-28 | B | `tests/stories/i-omniroute.test.mjs` |
|
|
518
|
+
| US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
|
|
446
519
|
| US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
|
|
447
520
|
| US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
|
|
448
521
|
| US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
|
|
@@ -450,6 +523,7 @@ table was filled in.
|
|
|
450
523
|
| US-12 | D | `tests/stories/d-notifications.test.mjs`, `tests/notify-test.mjs` |
|
|
451
524
|
| US-25 | D | `tests/stories/d-notifications.test.mjs` |
|
|
452
525
|
| US-29 | D | `tests/stories/d-notifications.test.mjs` |
|
|
526
|
+
| US-30 | D | `tests/stories/d-notifications.test.mjs`, `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
|
453
527
|
| US-13 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/notify-test.mjs`, `tests/oc-test.mjs` |
|
|
454
528
|
| US-14 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
|
455
529
|
| US-18 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
|
@@ -463,10 +537,15 @@ table was filled in.
|
|
|
463
537
|
| US-20 | G | `tests/stories/g-context-management.test.mjs` |
|
|
464
538
|
| US-21 | G | `tests/stories/g-context-management.test.mjs`, `tests/permission-test.mjs` |
|
|
465
539
|
| US-22 | G | `tests/stories/g-context-management.test.mjs` |
|
|
540
|
+
| US-32 | H | `tests/retire-old-sessions-test.mjs` |
|
|
541
|
+
| US-33 | H | `tests/prune-old-events-test.mjs` |
|
|
466
542
|
|
|
467
543
|
The nine suites under `tests/` are unit-style; the `tests/stories/` suites are
|
|
468
544
|
the behavioural layer, written one epic per file with every test name carrying
|
|
469
545
|
its story id. Run everything with `npm test`.
|
|
470
546
|
|
|
471
|
-
**No story is uncovered.**
|
|
472
|
-
|
|
547
|
+
**No story is uncovered.** The guard reports 33 stories, 325 test cases, and
|
|
548
|
+
33/33 stories with at least one behavioural test; US-1, US-2, US-3, US-6, and
|
|
549
|
+
all of epic F have tests, and the coverage guard fails the build if any of them
|
|
550
|
+
loses one. The scan covers the `tests/stories/` suites and the root-level
|
|
551
|
+
`tests/*-test.mjs` suites.
|
package/package.json
CHANGED
package/presets/README.md
CHANGED
|
@@ -26,8 +26,9 @@ file in without `mkdir` first.
|
|
|
26
26
|
Sources 1 and 2 win outright: if they parse, the bundled preset is never read.
|
|
27
27
|
Source 3 is a default, not a floor.
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
The pool files are not frozen at startup: the running process watches them,
|
|
30
|
+
reloads the slot table, re-binds the variant agents and validates every slot, so a
|
|
31
|
+
change here takes effect without a restart. Plugin CODE still needs one.
|
|
31
32
|
|
|
32
33
|
## The flat pool file (recommended)
|
|
33
34
|
|
|
@@ -37,7 +38,7 @@ in place, no JSON and no objects:
|
|
|
37
38
|
```text
|
|
38
39
|
# provider/model-id weight
|
|
39
40
|
opencode/big-pickle 3
|
|
40
|
-
opencode-go/
|
|
41
|
+
opencode-go/beam-free 2
|
|
41
42
|
opencode/deepseek-v4.1-flash-free 1
|
|
42
43
|
opencode/glm-5.3-flash-free 1
|
|
43
44
|
```
|
package/presets/free-tier.json
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
" 3. once EVERY slot is at its ceiling, the overflow goes to the least loaded slot,",
|
|
27
27
|
" so a burst past the summed ceilings degrades to even spreading and never blocks",
|
|
28
28
|
"",
|
|
29
|
-
"With this file: big-pickle takes 3 consecutive turns, then
|
|
29
|
+
"With this file: big-pickle takes 3 consecutive turns, then beam-free",
|
|
30
30
|
"(opencode-go) takes 2, then deepseek-v4.1-flash-free and glm-5.3-flash-free take 1",
|
|
31
31
|
"each -- sequence 1,1,1,2,2,3,4,...",
|
|
32
32
|
"Equal weights give a plain round robin in list order. 2,2,1,1 reproduces the old",
|
|
@@ -80,11 +80,11 @@
|
|
|
80
80
|
"Re-check any model you substitute against models.dev; this project does not look prices",
|
|
81
81
|
"up at runtime and will not guess at them for you.",
|
|
82
82
|
"",
|
|
83
|
-
"Changing this file
|
|
83
|
+
"Changing this file is picked up live: the pool watches its sources, reloads the slot table and validates every slot. A restart is only needed for plugin CODE, not for a pool edit."
|
|
84
84
|
],
|
|
85
85
|
"slots": [
|
|
86
86
|
{ "model": "opencode/big-pickle", "weight": 3 },
|
|
87
|
-
{ "model": "opencode-go/
|
|
87
|
+
{ "model": "opencode-go/beam-free", "weight": 2 },
|
|
88
88
|
{ "model": "opencode/deepseek-v4.1-flash-free", "weight": 1 },
|
|
89
89
|
{ "model": "opencode/glm-5.3-flash-free", "weight": 1 }
|
|
90
90
|
]
|