@lawaty/lacode 0.9.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/docs/TUNNEL.md CHANGED
@@ -15,8 +15,12 @@ gets it back.
15
15
  │ ▼ │ ONE argument: │ forced command) │
16
16
  │ lacode notify │ title\nmessage\n │ │ │
17
17
  │ │ urgency\nicon\nsound │ ▼ │
18
- │ │ │ notify-send / │
18
+ │ │ \nrequestID │ notify-send / │
19
19
  │ │ │ osascript │
20
+ │ ▲ │ ◄─────────────────────── │ │
21
+ │ │ the clicked button's │ │
22
+ │ │ name, on the forced │ │
23
+ │ │ command's stdout │ │
20
24
  └──────────────────┘ └────────────────────────┘
21
25
  ```
22
26
 
@@ -27,27 +31,67 @@ listening port, no systemd unit and nothing to sync: the plugin owns the
27
31
  transport, and it exists only while opencode runs — which is the only time a
28
32
  notification matters.
29
33
 
30
- **One argument, not five.** ssh concatenates everything after the host into a
34
+ **One argument, not six.** ssh concatenates everything after the host into a
31
35
  single command string, and a forced command starts with *no* positional
32
- parameters. A sender that passes five quoted arguments arrives as the single
33
- string `"a b c d e"`; a receiver reading `$1..$5` sees five empty strings, falls
34
- back to five defaults, and pops up a blank notification. So the payload crosses
35
- as one newline-delimited argument:
36
+ parameters. A sender that passes six quoted arguments arrives as the single
37
+ string `"a b c d e f"`; a receiver reading `$1..$6` sees six empty strings,
38
+ falls back to six defaults, and pops up a blank notification. So the payload
39
+ crosses as one newline-delimited argument:
36
40
 
37
41
  ```
38
- title\nmessage\nurgency\nicon\nsound
42
+ title\nmessage\nurgency\nicon\nsound\nrequestID
39
43
  ```
40
44
 
41
- Five fields — `title`, `message`, `urgency`, `icon`, `sound`. `sound` is `none`,
42
- `done`, `question`, `permission` or `error`. A sender that stops at `icon`
43
- simply leaves it empty, and an empty or unrecognised name is `none`, so a
44
- four-field payload is a valid one.
45
+ Six fields — `title`, `message`, `urgency`, `icon`, `sound`, `requestID`.
46
+ `sound` is `none`, `done`, `question`, `permission` or `error`. The request id
47
+ is **last**, and it is opaque: it is never shown to you, never passed to a
48
+ command, and never used to build an argument — the only thing it decides is
49
+ whether the notification carries buttons. A sender that stops at `icon`
50
+ simply leaves the rest empty, and an empty or unrecognised name is `none`, so a
51
+ four-field payload is a valid one; a payload with fewer than six fields carries
52
+ no request id at all, and therefore no buttons, so an older sender still works
53
+ against a receiver that expects six.
45
54
 
46
55
  Newline is safe as the delimiter because both ends strip control characters from
47
56
  the free-text fields before assembling or reading it, and `urgency`, `icon` and
48
57
  `sound` come from whitelists. No field can contain the delimiter, so a message
49
58
  full of spaces, quotes or `*` survives intact.
50
59
 
60
+ ## The way back
61
+
62
+ The hop was one-way until now: the notification went up to the desktop and
63
+ nothing came back, because there was nothing on the wire to carry an answer.
64
+ It is two-way now, and what made it two-way was not a new mechanism. **The
65
+ desktop end is an ssh forced command, so its stdout comes back down the same
66
+ ssh connection the notification went up.** That channel was already there and
67
+ nobody used it: a forced command's stdout is carried to whoever opened the
68
+ connection, so the answer rides home on the connection that is already open —
69
+ no second connection, no listener, no port, no new key, no HTTP server.
70
+
71
+ A permission notification that carries a request id is raised with three
72
+ buttons: **Allow once**, **Always allow** and **Deny**. `notify-send` waits
73
+ for the click and prints the chosen button's *name* on stdout — `once`,
74
+ `always` or `reject`, and "reject" is opencode's own word for deny, not ours.
75
+ The name needs no translation: it is already one of the values the server
76
+ accepts as a permission reply. The plugin reads it off the helper's stdout and
77
+ answers the permission through opencode's own reply route.
78
+
79
+ Two things follow from the shape. An **empty** stdout means the popup was
80
+ dismissed without a button being chosen: nothing is answered, and the
81
+ permission is left exactly as it was. And a permission that was **already
82
+ answered** — most often in the terminal — comes back as "not found" rather
83
+ than as a failure, because the user did what they had to do and clicking a
84
+ stale notification is not an error.
85
+
86
+ The wait is bounded: ten minutes on the desktop side, and the hop's own
87
+ deadline is stretched to outlast it, because an answer cut off by a timeout is
88
+ worse than no answer at all.
89
+
90
+ The buttons are **Linux-only**. `notify-send --action` is the only one of the
91
+ three channels that can offer one — `osascript` and `terminal-notifier` have no
92
+ action support — so a macOS desktop gets a plain notice, and the answer there
93
+ still has to be given in the terminal.
94
+
51
95
  ## Setting it up
52
96
 
53
97
  Once, on the **remote host** (where opencode runs):
@@ -95,11 +139,20 @@ SOUND is optional and last: `none`, `done`, `question`, `permission` or `error`,
95
139
  one bundled tone per outcome. Leave it off and nothing plays; the line above
96
140
  without a fifth word is correct.
97
141
 
98
- `notify` is fire-and-forget by design. An unreachable desktop, a missing key, no
99
- notifier installed at all — all of them print a warning on stderr and exit 0. A
100
- notification must never be able to fail the process that tried to send one; an
101
- agent must not go down because a laptop went to sleep. `forward` is the
102
- exception: it exits with the remote's status, because its caller wants to know.
142
+ `LACODE_REQUEST_ID` in the environment is the one thing that makes a send
143
+ actionable: it is the permission request id, it is opaque, and it is set on the
144
+ helper by the plugin rather than by you. A send with it carries the three
145
+ buttons on Linux and prints the name of the clicked one on stdout; a send
146
+ without it is an ordinary notice that prints nothing.
147
+
148
+ A plain `notify` is fire-and-forget by design. An unreachable desktop, a
149
+ missing key, no notifier installed at all — all of them print a warning on
150
+ stderr and exit 0. A notification must never be able to fail the process that
151
+ tried to send one; an agent must not go down because a laptop went to sleep.
152
+ `forward` is the exception: it exits with the remote's status, because its
153
+ caller wants to know. An **actionable** notify is the other exception, and it
154
+ is bounded rather than open-ended: it waits for the click, because the answer
155
+ is the whole point of it, and it still exits 0 whatever happens.
103
156
 
104
157
  On macOS the text is passed to `osascript` as arguments to an `on run argv`
105
158
  handler, never spliced into the script source — a session title is not trusted
@@ -115,6 +168,14 @@ delivers locally through the platform channel — `notify-send` on Linux,
115
168
  channel as a local send, **the desktop half is not Linux-only**: a Mac desktop
116
169
  receives notifications too.
117
170
 
171
+ The buttons are the exception to that. Only `notify-send` can offer one, so a
172
+ payload with a request id is an actionable notification on Linux and a plain
173
+ notice — one that prints nothing — on macOS. What the receiver prints on stdout
174
+ is the chosen button's name, and that stdout is what carries the answer back up
175
+ the connection (see [The way back](#the-way-back)). The request id itself is
176
+ never passed to a command and never printed — only its emptiness is ever
177
+ tested.
178
+
118
179
  It never forwards: `SSH_CLIENT` is set for a forced command as well, so it calls
119
180
  the local channel directly rather than `notify()` (which would loop back).
120
181
 
@@ -152,6 +213,11 @@ documented with the other plugin options in the README. Set it to `0`, `false`,
152
213
  `off` or `no` to silence notifications; anything else — a typo included — leaves
153
214
  sound on, because a misspelt variable must not be how you turn a feature off.
154
215
 
216
+ `LACODE_REQUEST_ID` is absent for the same kind of reason, and it is not yours
217
+ to set either: the plugin puts the permission request id on the helper
218
+ process, and the helper carries it as the payload's sixth field. The hop only
219
+ ever asks whether it is there.
220
+
155
221
  ## The reverse forward
156
222
 
157
223
  A host that sets `OC_TUNNEL_PORT` and leaves `OC_LOCAL_HOST` unset reaches the
@@ -161,3 +227,7 @@ desktop through a reverse forward: `sendRemote` falls back to an ssh call to
161
227
  (`bin/oc-notify-receiver`). That receiver is the Linux end of the hop: it
162
228
  delivers through `notify-send`, and libnotify is a Linux thing, so a Mac cannot
163
229
  be this end of the tunnel.
230
+
231
+ The way back works the same way here: the receiver's stdout comes back down
232
+ the forward, on the connection the notification went up, so a click on one of
233
+ the three buttons is carried home the same way it is on the direct hop.
@@ -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
@@ -193,7 +210,7 @@ does not.
193
210
 
194
211
  **Story ID(s) for tests:** US-11
195
212
  **Acceptance criteria:**
196
- - [ ] Given a session went busy and is now idle, When the idle event fires, Then a `done` notification is delivered.
213
+ - [ ] Given a session went busy and is now idle, When the idle event fires, Then a `done` notification is delivered and stays on the desktop until the user closes it.
197
214
  - [ ] Given a session ends with an unrecognized error, When the error event fires, Then an `error` notification is delivered, it bypasses the duplicate-suppression cooldown, and it carries the error's own message.
198
215
  - [ ] Given a recognized rate-limit or quota error, or a user abort, When a session error event fires, Then no failure notification is raised — the pool already handles the former and the user already knows the latter.
199
216
  - [ ] Given a session that never went busy, When it goes idle, Then no notification is sent.
@@ -211,7 +228,7 @@ does not.
211
228
  - [ ] Given a question and a completion occur inside the same cooldown window, When both are evaluated, Then they are not deduplicated against each other.
212
229
 
213
230
  ### US-25: Notification when opencode asks for permission
214
- **As a** user, **I want** to be notified when opencode is blocked asking me for permission — including permission to reach a directory outside the worktree — **so that** a silent permission prompt is never mistaken for a hung agent.
231
+ **As a** user, **I want** to be notified when opencode is blocked asking me for permission — including permission to reach a directory outside the worktree — **so that** a silent permission prompt is never mistaken for a hung agent, and so that I can allow or deny it where the notification reaches me instead of going back to the terminal.
215
232
 
216
233
  **Story ID(s) for tests:** US-25
217
234
  **Acceptance criteria:**
@@ -219,8 +236,14 @@ does not.
219
236
  - [ ] Given opencode asks to reach a directory outside the worktree, When the prompt fires, Then the notification names that directory, resolved from `metadata.parentDir`/`metadata.filepath` and with the trailing glob trimmed, because a glob is not something the user can judge.
220
237
  - [ ] Given the permission is granted, denied, or dismissed, When `permission.replied` fires, Then no notification is sent — the prompt was the thing worth interrupting for, not the answer — and only the parked-session block is cleared.
221
238
  - [ ] Given a session is parked on a permission prompt, When the idle event fires, Then no "finished" notification is sent, because nothing finished.
222
- - [ ] Given the plugin observes a permission prompt, When it handles the event, Then it is a read-only observer: the payload is not mutated and no `permission.ask` hook is registered, because that hook's `output.status` is the permission decision itself.
239
+ - [ ] Given the plugin observes a permission prompt, When it handles the event, Then it registers no `permission.ask` hook and mutates nothing in the payload — that hook's `output.status` is the permission decision itself, and deciding a prompt inside a handler is not the same as answering the one the user was asked for. A permission is answered through the server's own reply route, and only when the user says which answer they meant.
223
240
  - [ ] Given a `permission` notification is raised, When it is evaluated against the cooldown, Then it bypasses it, since an unannounced block is the failure this story exists to remove.
241
+ - [ ] Given a permission prompt on Linux, When the notification is delivered, Then it is exactly one notification carrying three buttons — **Allow once**, **Always allow** and **Deny** — and the click raises no second notification.
242
+ - [ ] Given one of those buttons is clicked, When the answer comes back over the hop the notification came up, Then the permission is answered with that button's meaning through the server's reply route; the button names are opencode's own reply values (`once`, `always` and `reject` — the server's word for deny), so a click needs no translation to become an answer.
243
+ - [ ] Given the popup is dismissed without a button being chosen, When the notification returns, Then nothing is answered and the permission is left exactly as it was.
244
+ - [ ] Given the permission was already answered in the terminal, When a button is clicked, Then the reply is a no-op and is not reported as a failure.
245
+ - [ ] Given the desktop is a macOS one, When the same prompt is delivered, Then it is a plain notice with no buttons, because `osascript` and `terminal-notifier` have no action support and the answer still has to be given in the terminal there.
246
+ - [ ] Given a question is asked, When its notification is delivered, Then it carries no buttons and answers nothing, because a question is not a permission: the v1 client this plugin has has no question API at all, and the v2 routes need a bound socket the default TUI does not have.
224
247
 
225
248
  ### US-29: Notification sounds distinguish outcomes and can be switched off
226
249
  **As a** user running many parallel sessions, **I want** each notification to arrive with its own sound, **so that** I can tell a finished session from a question or a permission prompt without looking at the popup.
@@ -234,6 +257,22 @@ does not.
234
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.
235
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).
236
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
+
237
276
  ---
238
277
 
239
278
  ## Epic E — Remote hosts over SSH
@@ -374,7 +413,7 @@ agents read it before they explore, then keeps it current on its own.
374
413
  **Acceptance criteria:**
375
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.
376
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.
377
- - [ ] 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.
378
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".
379
418
 
380
419
  ### US-20: The map stays current without being asked
@@ -418,10 +457,49 @@ Enforcement is split deliberately, and the split matters: **what the permission
418
457
 
419
458
  ---
420
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
+
421
497
  ## Coverage
422
498
 
423
- Every story below has at least one behavioural test in `tests/stories/`, and
424
- `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:
425
503
  it fails if a story here has no test, and it fails if a test names a story this
426
504
  document does not define. The acceptance criteria above are unchanged; only this
427
505
  table was filled in.
@@ -437,6 +515,7 @@ table was filled in.
437
515
  | US-6 | B | `tests/stories/b-provider-resilience.test.mjs` |
438
516
  | US-7 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-models-test.mjs` |
439
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` |
440
519
  | US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
441
520
  | US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
442
521
  | US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
@@ -444,6 +523,7 @@ table was filled in.
444
523
  | US-12 | D | `tests/stories/d-notifications.test.mjs`, `tests/notify-test.mjs` |
445
524
  | US-25 | D | `tests/stories/d-notifications.test.mjs` |
446
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` |
447
527
  | US-13 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/notify-test.mjs`, `tests/oc-test.mjs` |
448
528
  | US-14 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
449
529
  | US-18 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
@@ -457,10 +537,15 @@ table was filled in.
457
537
  | US-20 | G | `tests/stories/g-context-management.test.mjs` |
458
538
  | US-21 | G | `tests/stories/g-context-management.test.mjs`, `tests/permission-test.mjs` |
459
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` |
460
542
 
461
543
  The nine suites under `tests/` are unit-style; the `tests/stories/` suites are
462
544
  the behavioural layer, written one epic per file with every test name carrying
463
545
  its story id. Run everything with `npm test`.
464
546
 
465
- **No story is uncovered.** US-1, US-2, US-3, US-6, and all of epic F have tests,
466
- 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.9.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
  ]