@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/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 ?? join2(homedir(), ".ssh", "lacode_notify"),
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=${join2(homedir(), ".ssh", "lacode-cm-%C")}`
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, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
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 = join2(PACKAGE_ROOT, "sounds", `${sound}.wav`);
194
- if (!existsSync(path)) return;
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 action = actionable ? result.stdout.trim() : "";
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 result = isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
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,
@@ -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.json` (its parent directory is created for you)
11
- 3. the bundled `presets/free-tier.json`
12
- 4. a built-in fallback, so routing always works
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. Nothing hot-reloads —
16
- change a preset, then restart every opencode process.
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
- Nothing hot-reloads: after editing your config, restart every running opencode
73
- process.
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/minimax-m3.1-flash-free
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,
@@ -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 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.
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 in `tests/stories/`, and
430
- `tests/stories/coverage.test.mjs` enforces that mechanically in both directions:
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.** US-1, US-2, US-3, US-6, and all of epic F have tests,
472
- and the coverage guard fails the build if any of them loses one.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/presets/README.md CHANGED
@@ -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
- Nothing is hot-reloaded. Change a preset, then restart every running opencode
30
- process.
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/minimax-m3.1-flash-free 2
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
  ```
@@ -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 minimax-m3.1-flash-free",
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 requires restarting every opencode process: plugins are not hot-reloaded."
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/minimax-m3.1-flash-free", "weight": 2 },
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
  ]