@lawaty/lacode 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lacode.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  // bin/lacode
4
4
  import { fileURLToPath } from "node:url";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { spawnSync as spawnSync2 } from "node:child_process";
5
7
 
6
8
  // src/tunnel/index.ts
7
9
  import { spawnSync } from "node:child_process";
@@ -19,6 +21,7 @@ function resolveTunnel(env = process.env) {
19
21
  const port = Number(env.OC_TUNNEL_PORT);
20
22
  return {
21
23
  user: env.OC_LOCAL_USER ?? "",
24
+ host: env.OC_LOCAL_HOST ?? "",
22
25
  port: Number.isFinite(port) && port > 0 ? port : 10022,
23
26
  key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
24
27
  connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
@@ -41,6 +44,15 @@ var safeText = (raw, fallback, max) => {
41
44
  var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
42
45
  var safeMessage = (raw) => safeText(raw ?? "", "", 400);
43
46
  var FIELD_SEPARATOR = "\n";
47
+ function parsePayload(raw) {
48
+ const [title, message, urgency, icon] = raw.split(FIELD_SEPARATOR);
49
+ return {
50
+ title: safeTitle(title),
51
+ message: safeMessage(message),
52
+ urgency: normalizeUrgency(urgency),
53
+ icon: normalizeIcon(icon)
54
+ };
55
+ }
44
56
  var encodePayload = (request) => [
45
57
  safeTitle(request.title),
46
58
  safeMessage(request.message),
@@ -59,6 +71,14 @@ var SSH_OPTS = [
59
71
  "-o",
60
72
  "StrictHostKeyChecking=accept-new"
61
73
  ];
74
+ var CONTROL_OPTS = [
75
+ "-o",
76
+ "ControlMaster=auto",
77
+ "-o",
78
+ "ControlPersist=300",
79
+ "-o",
80
+ `ControlPath=${join(homedir(), ".ssh", "lacode-cm-%C")}`
81
+ ];
62
82
  var run = (command2, args, timeout) => {
63
83
  try {
64
84
  const result = spawnSync(command2, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
@@ -120,19 +140,20 @@ function sendLocal(request, platform = process.platform) {
120
140
  }
121
141
  function sendRemote(request, tunnel) {
122
142
  if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
123
- const args = [
124
- ...SSH_OPTS,
125
- "-o",
126
- `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`,
127
- "-i",
128
- tunnel.key,
129
- "-p",
130
- String(tunnel.port),
131
- `${tunnel.user}@localhost`,
132
- "--",
133
- encodePayload(request)
134
- ];
135
- const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
143
+ const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
144
+ if (tunnel.host) {
145
+ const result2 = run(
146
+ "ssh",
147
+ [...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
148
+ tunnel.connectTimeoutMs + 2e3
149
+ );
150
+ return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
151
+ }
152
+ const result = run(
153
+ "ssh",
154
+ [...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
155
+ tunnel.connectTimeoutMs + 2e3
156
+ );
136
157
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
137
158
  }
138
159
  function notify(request, env = process.env, platform = process.platform) {
@@ -158,6 +179,7 @@ function describe(env = process.env, platform = process.platform) {
158
179
  return {
159
180
  running: isRemote(env) ? "remote (over ssh)" : "local",
160
181
  user: tunnel.user || "(unset)",
182
+ host: tunnel.host || "(unset)",
161
183
  port: tunnel.port,
162
184
  key: tunnel.key,
163
185
  channel: localChannel(platform),
@@ -173,8 +195,8 @@ if (!process.features?.typescript) {
173
195
  console.error("lacode: this node cannot load TypeScript sources; node >= 22.18 is required.");
174
196
  process.exit(1);
175
197
  }
176
- const { spawnSync: spawnSync2 } = await import("node:child_process");
177
- const result = spawnSync2(
198
+ const { spawnSync: spawnSync3 } = await import("node:child_process");
199
+ const result = spawnSync3(
178
200
  process.execPath,
179
201
  ["--experimental-strip-types", "--no-warnings", fileURLToPath(import.meta.url), ...process.argv.slice(2)],
180
202
  { stdio: "inherit", env: { ...process.env, LACODE_STRIP_TYPECHECKED: "1" } }
@@ -247,6 +269,41 @@ switch (command) {
247
269
  process.exit(0);
248
270
  break;
249
271
  }
272
+ case "receive": {
273
+ const request = parsePayload(process.env.SSH_ORIGINAL_COMMAND ?? "");
274
+ const result = sendLocal(request);
275
+ if (!result.ok) console.error(`lacode receive: ${result.detail ?? "not delivered"}`);
276
+ process.exit(result.ok ? 0 : 1);
277
+ break;
278
+ }
279
+ case "tunnel": {
280
+ const sub = argv.shift();
281
+ if (sub !== "setup") {
282
+ console.error("lacode tunnel: usage: lacode tunnel setup");
283
+ process.exit(2);
284
+ }
285
+ const { user, host, key } = resolveTunnel();
286
+ const pub = `${key}.pub`;
287
+ if (!existsSync(key)) {
288
+ const gen = spawnSync2("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", "lacode-notify", "-f", key], { stdio: ["ignore", "ignore", "inherit"] });
289
+ if (gen.status !== 0) {
290
+ console.error("lacode tunnel setup: ssh-keygen failed; create the key by hand and re-run.");
291
+ process.exit(1);
292
+ }
293
+ console.log(`Generated ${key}`);
294
+ }
295
+ const pubkey = existsSync(pub) ? readFileSync(pub, "utf8").trim() : "(public key missing)";
296
+ const self = process.argv[1] ?? "lacode";
297
+ console.log("\nOn the REMOTE host (where opencode runs), export:");
298
+ console.log(` OC_LOCAL_USER=${user || "<desktop-user>"}`);
299
+ console.log(` OC_LOCAL_HOST=${host || "<desktop-host>"}`);
300
+ console.log(` OC_NOTIFY_KEY=${key}`);
301
+ console.log("\nAdd ONE line to the DESKTOP's ~/.ssh/authorized_keys (this is the trust boundary):");
302
+ console.log(` restrict,command="${self} receive" ${pubkey}`);
303
+ console.log("\nDirect ssh + ControlPersist: no reverse forward, no systemd unit, no port, nothing to sync.");
304
+ process.exit(0);
305
+ break;
306
+ }
250
307
  default:
251
308
  console.error(`lacode: unknown command ${JSON.stringify(safeTitle(command))}`);
252
309
  console.error(USAGE);
package/dist/pool.js CHANGED
@@ -10,12 +10,13 @@ var CATALOG_TTL_MS = 10 * 60 * 1e3;
10
10
  var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
11
11
  var STUCK_IDLE_MS = 3 * 60 * 1e3;
12
12
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
13
+ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
13
14
  var MAX_WEIGHT = 100;
14
15
  var MAX_SLOTS = 4;
15
16
  var COOLDOWN_BASE_MS = 60 * 1e3;
16
17
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
17
18
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
18
- var BASES = ["explore-fast", "implement-fast", "context-manager"];
19
+ var BASES = ["explore", "implement", "context"];
19
20
  var findPackageRoot = (start) => {
20
21
  let dir = start;
21
22
  for (let up = 0; up < 6; up++) {
@@ -141,6 +142,7 @@ function readWeight(entry, at, model, problems) {
141
142
  }
142
143
  return weight;
143
144
  }
145
+ var UNAVAILABLE_PATTERN = /endpoint\s+is\s+unavailable/i;
144
146
  var LIMIT_PATTERNS = [
145
147
  /rate[\s_-]?limit/i,
146
148
  /usage limit/i,
@@ -148,9 +150,11 @@ var LIMIT_PATTERNS = [
148
150
  /\b429\b/,
149
151
  /quota/i,
150
152
  /freelimiterror/i,
151
- /overloaded/i
153
+ /overloaded/i,
154
+ UNAVAILABLE_PATTERN
152
155
  ];
153
156
  var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text));
157
+ var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
154
158
  var statedCooldown = (text) => {
155
159
  const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
156
160
  if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
@@ -332,6 +336,22 @@ function recordStrike(s, model, reason, stated) {
332
336
  }
333
337
  return strike;
334
338
  }
339
+ function coolUnavailable(s, model) {
340
+ if (!s.dirReady) return;
341
+ const now = Date.now();
342
+ const prior = s.cooling.get(model);
343
+ const until = now + UNAVAILABLE_COOLDOWN_MS;
344
+ if (prior && prior.until > until) return prior;
345
+ const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
346
+ s.cooling.set(model, strike);
347
+ const path = join(s.dir, limitFile(model));
348
+ try {
349
+ writeFileSync(`${path}.tmp`, JSON.stringify(strike));
350
+ renameSync(`${path}.tmp`, path);
351
+ } catch {
352
+ }
353
+ return strike;
354
+ }
335
355
  function refreshCatalog(s, serverUrl) {
336
356
  if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
337
357
  s.catalogAt = Date.now();
@@ -448,6 +468,7 @@ export {
448
468
  PRESETS_DIR,
449
469
  STUCK_IDLE_MS,
450
470
  STUCK_MIN_AGE_MS,
471
+ UNAVAILABLE_COOLDOWN_MS,
451
472
  USER_CONFIG_DIR,
452
473
  USER_MODELS_PATH,
453
474
  acquire,
@@ -455,6 +476,7 @@ export {
455
476
  bindChild,
456
477
  claimKey,
457
478
  coolProvider,
479
+ coolUnavailable,
458
480
  ensureSlots,
459
481
  ensureUserConfigDir,
460
482
  findStuck,
@@ -462,6 +484,7 @@ export {
462
484
  init,
463
485
  isAvailable,
464
486
  isLimitText,
487
+ isUnavailableText,
465
488
  noteActivity,
466
489
  parseSlots,
467
490
  pickSlot,
package/dist/tunnel.js CHANGED
@@ -14,6 +14,7 @@ function resolveTunnel(env = process.env) {
14
14
  const port = Number(env.OC_TUNNEL_PORT);
15
15
  return {
16
16
  user: env.OC_LOCAL_USER ?? "",
17
+ host: env.OC_LOCAL_HOST ?? "",
17
18
  port: Number.isFinite(port) && port > 0 ? port : 10022,
18
19
  key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
19
20
  connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
@@ -36,6 +37,15 @@ var safeText = (raw, fallback, max) => {
36
37
  var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
37
38
  var safeMessage = (raw) => safeText(raw ?? "", "", 400);
38
39
  var FIELD_SEPARATOR = "\n";
40
+ function parsePayload(raw) {
41
+ const [title, message, urgency, icon] = raw.split(FIELD_SEPARATOR);
42
+ return {
43
+ title: safeTitle(title),
44
+ message: safeMessage(message),
45
+ urgency: normalizeUrgency(urgency),
46
+ icon: normalizeIcon(icon)
47
+ };
48
+ }
39
49
  var encodePayload = (request) => [
40
50
  safeTitle(request.title),
41
51
  safeMessage(request.message),
@@ -54,6 +64,14 @@ var SSH_OPTS = [
54
64
  "-o",
55
65
  "StrictHostKeyChecking=accept-new"
56
66
  ];
67
+ var CONTROL_OPTS = [
68
+ "-o",
69
+ "ControlMaster=auto",
70
+ "-o",
71
+ "ControlPersist=300",
72
+ "-o",
73
+ `ControlPath=${join(homedir(), ".ssh", "lacode-cm-%C")}`
74
+ ];
57
75
  var run = (command, args, timeout) => {
58
76
  try {
59
77
  const result = spawnSync(command, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
@@ -115,19 +133,20 @@ function sendLocal(request, platform = process.platform) {
115
133
  }
116
134
  function sendRemote(request, tunnel) {
117
135
  if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
118
- const args = [
119
- ...SSH_OPTS,
120
- "-o",
121
- `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`,
122
- "-i",
123
- tunnel.key,
124
- "-p",
125
- String(tunnel.port),
126
- `${tunnel.user}@localhost`,
127
- "--",
128
- encodePayload(request)
129
- ];
130
- const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
136
+ const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
137
+ if (tunnel.host) {
138
+ const result2 = run(
139
+ "ssh",
140
+ [...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
141
+ tunnel.connectTimeoutMs + 2e3
142
+ );
143
+ return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
144
+ }
145
+ const result = run(
146
+ "ssh",
147
+ [...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
148
+ tunnel.connectTimeoutMs + 2e3
149
+ );
131
150
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
132
151
  }
133
152
  function notify(request, env = process.env, platform = process.platform) {
@@ -153,6 +172,7 @@ function describe(env = process.env, platform = process.platform) {
153
172
  return {
154
173
  running: isRemote(env) ? "remote (over ssh)" : "local",
155
174
  user: tunnel.user || "(unset)",
175
+ host: tunnel.host || "(unset)",
156
176
  port: tunnel.port,
157
177
  key: tunnel.key,
158
178
  channel: localChannel(platform),
@@ -172,6 +192,7 @@ export {
172
192
  normalizeIcon,
173
193
  normalizeUrgency,
174
194
  notify,
195
+ parsePayload,
175
196
  resolveTunnel,
176
197
  safeMessage,
177
198
  safeTitle,
package/docs/CONTEXT.md CHANGED
@@ -69,14 +69,14 @@ something has gone wrong.
69
69
 
70
70
  ## How it stays current
71
71
 
72
- The `context-manager` agent maintains the map. It runs in two ways: on its own,
72
+ The `context` agent maintains the map. It runs in two ways: on its own,
73
73
  after a session that changed something, and on demand through the three
74
74
  commands — both of which go through the same agent, so they behave the same way.
75
75
 
76
76
  ### Automatically
77
77
 
78
78
  After a **root** session goes idle, and only if that session edited a file
79
- inside the project, LaCode starts a `context-manager` run:
79
+ inside the project, LaCode starts a `context` run:
80
80
 
81
81
  1. It collects the paths the session touched, dropping anything generated,
82
82
  vendored or outside the project (`node_modules`, `dist`, `coverage`,
@@ -123,7 +123,7 @@ as you wrote it — the same merge-without-clobber rule the agents follow.
123
123
 
124
124
  ## One writer
125
125
 
126
- Only `context-manager` writes the map. Every other agent is told never to edit
126
+ Only `context` writes the map. Every other agent is told never to edit
127
127
  it, and to say in its final report if its work revealed something that belongs
128
128
  in there. The cartographer, in turn, is denied permission to write anything
129
129
  outside the map — not by convention, but by an `edit` rule that allows
@@ -199,7 +199,7 @@ thing to check is that permission rule, not the map's content.
199
199
  | `auto update finished without changing the map` | Nothing landed. Either nothing needed saying, or the cartographer could not write. |
200
200
  | `auto update failed: …` | The run errored. The paths it was given are queued again for the next window, so the work is not lost. |
201
201
  | `context map is not writable: …` | Your config's one-writer rule does not match the path opencode evaluates. Nothing will update until you fix it. |
202
- | `context map has more than one writer: …` | An agent other than `context-manager` would be granted the map. The offending agent and the winning rule are named. |
202
+ | `context map has more than one writer: …` | An agent other than `context` would be granted the map. The offending agent and the winning rule are named. |
203
203
 
204
204
  ## Reference
205
205
 
package/docs/TUNNEL.md CHANGED
@@ -19,7 +19,15 @@ gets it back.
19
19
  └──────────────────┘ └────────────────────────┘
20
20
  ```
21
21
 
22
- Two things are load-bearing here.
22
+ **Direct ssh (the default).** Set `OC_LOCAL_HOST` and the hop is a plain `ssh`
23
+ to the desktop with `ControlMaster=auto` + `ControlPersist`, so one warm
24
+ connection carries every notification. There is no reverse forward, no listening
25
+ port, no systemd unit and nothing to sync: the plugin owns the transport. The
26
+ desktop needs one `authorized_keys` line, which `lacode tunnel setup` prints. The
27
+ reverse-forward description below is the legacy path, used only when
28
+ `OC_LOCAL_HOST` is unset.
29
+
30
+ **Reverse forward (legacy).** Two things are load-bearing here.
23
31
 
24
32
  **The reverse forward.** The remote opens an SSH connection to *your* machine
25
33
  and asks sshd on the desktop to listen on a port. A notification is then an ssh
@@ -74,7 +82,8 @@ useful on a Mac rather than reporting only whether a Linux binary is installed.
74
82
 
75
83
  | Variable | Default | Meaning |
76
84
  | --- | --- | --- |
77
- | `OC_LOCAL_USER` | *(unset)* | The desktop user the tunnel authenticates as. **Required for the remote hop.** |
85
+ | `OC_LOCAL_USER` | *(unset)* | The desktop user the hop authenticates as. **Required for the remote hop.** |
86
+ | `OC_LOCAL_HOST` | *(unset)* | Desktop hostname for the **direct ssh** hop. Set it and the reverse forward/port/systemd unit are not used at all. |
78
87
  | `OC_TUNNEL_PORT` | `10022` | Reverse-forward listener port on the desktop. Give each remote host its own. |
79
88
  | `OC_NOTIFY_KEY` | `~/.ssh/lacode_notify` | The dedicated key. Never a personal key. |
80
89
  | `OC_CONNECT_TIMEOUT_MS` | `5000` | Ceiling on the hop. |
@@ -16,7 +16,7 @@ document; everything here is testable offline with a stub client and a
16
16
  `mkdtempSync` state directory.
17
17
 
18
18
  **Terminology.** A *slot* is one entry in `presets/free-tier.json`, carrying a
19
- model and a `weight`. A *base* is a pooled agent type (`explore-fast`), and its slot
19
+ model and a `weight`. A *base* is a pooled agent type (`explore`), and its slot
20
20
  holders are `<base>-<slot>`, hidden in `opencode.jsonc`. A *claim* is a running
21
21
  spawn recorded in `claims.<pid>.json`. A *context map* is the five-file
22
22
  `.opencode/context/` codemap described in Epic G.
@@ -45,7 +45,7 @@ spawn recorded in `claims.<pid>.json`. A *context map* is the five-file
45
45
  **Story ID(s) for tests:** US-1
46
46
  **Acceptance criteria:**
47
47
  - [ ] Given the root agent `build` with read/glob/grep denied, When a read-family tool is invoked, Then the call is denied and the agent is pointed at an exploration base rather than executing inline.
48
- - [ ] Given a pooled base such as `explore-fast` is spawned, When the plugin rewrites the spawn, Then the concrete model selected is a slot declared in `presets/free-tier.json`.
48
+ - [ ] Given a pooled base such as `explore` is spawned, When the plugin rewrites the spawn, Then the concrete model selected is a slot declared in `presets/free-tier.json`.
49
49
  - [ ] Given a pool whose slots are all zero-cost, When N exploration spawns run, Then none of them is dispatched to a paid model.
50
50
 
51
51
  ### US-2: One knob selects the exploration/implementation model
@@ -125,9 +125,9 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
125
125
  - [ ] Given a slot names a paid model, When the pool loads, Then the slot is accepted and routed to without a free-tier filter rejecting it.
126
126
  - [ ] Given a model id contains a vendor prefix with slashes (e.g. `openrouter/nvidia/model:free`), When it is split into provider and model id, Then the split is on the first slash only and the id tail is preserved.
127
127
  - [ ] Given more slots are declared than the pool allows, When the pool loads, Then the surplus entries are dropped with a warning naming the cap.
128
- - [ ] Given a provider configured with **no** `whitelist` key, When the config hook binds a slot or a `deepModel` on that provider, Then no `whitelist` is written for it, because an absent whitelist already allows every model the provider offers.
129
- - [ ] Given a provider the pool never names a slot or a `deepModel` on, When the config hook runs, Then that provider entry is left exactly as it was, with no `whitelist` key added.
130
- - [ ] Given a provider that already carries a `whitelist`, When the config hook binds a slot model or a `deepModel` on it, Then the model id is appended and every entry the user wrote is preserved in order.
128
+ - [ ] Given a provider configured with **no** `whitelist` key, When the config hook binds a slot on that provider, Then no `whitelist` is written for it, because an absent whitelist already allows every model the provider offers.
129
+ - [ ] Given a provider the pool never names a slot on, When the config hook runs, Then that provider entry is left exactly as it was, with no `whitelist` key added.
130
+ - [ ] Given a provider that already carries a `whitelist`, When the config hook binds a slot model on it, Then the model id is appended and every entry the user wrote is preserved in order.
131
131
  - [ ] Given any provider after the config hook has run, When its whitelist is inspected, Then it is either absent or non-empty, because an empty allowlist names no model at all and opencode deletes the provider outright.
132
132
 
133
133
  `provider.<name>.whitelist` is an **allowlist**, not a to-do list: opencode
@@ -183,7 +183,8 @@ does not.
183
183
  **Story ID(s) for tests:** US-11
184
184
  **Acceptance criteria:**
185
185
  - [ ] Given a session went busy and is now idle, When the idle event fires, Then a `done` notification is delivered.
186
- - [ ] Given a session errors, When the error event fires, Then an `error` notification is delivered and it bypasses the duplicate-suppression cooldown.
186
+ - [ ] Given a session ends with an unrecognized error, When the error event fires, Then an `error` notification is delivered, it bypasses the duplicate-suppression cooldown, and it carries the error's own message.
187
+ - [ ] Given a recognized rate-limit or quota error, or a user abort, When a session error event fires, Then no failure notification is raised — the pool already handles the former and the user already knows the latter.
187
188
  - [ ] Given a session that never went busy, When it goes idle, Then no notification is sent.
188
189
  - [ ] Given a subagent session or a cartographer session, When it goes idle, Then it is suppressed.
189
190
  - [ ] Given the notify helper exits non-zero or writes to stderr, When the result is evaluated, Then the delivery is reported as failed rather than sent.
@@ -329,17 +330,17 @@ makes agents read it before they explore, then keeps it current on its own.
329
330
 
330
331
  **Story ID(s) for tests:** US-19
331
332
  **Acceptance criteria:**
332
- - [ ] Given a config that defines none of LaCode's agents, When the config hook runs, Then a `context-manager` subagent is injected, and it carries no model of its own, because the pool is the only thing that decides which model runs it.
333
- - [ ] Given the injected agent set, When the slot variants are built, Then a hidden `context-manager-<slot>` variant exists for every slot the pool can hand out, so a cartography run borrows shared capacity instead of a dedicated model.
333
+ - [ ] Given a config that defines none of LaCode's agents, When the config hook runs, Then a `context` subagent is injected, and it carries no model of its own, because the pool is the only thing that decides which model runs it.
334
+ - [ ] Given the injected agent set, When the slot variants are built, Then a hidden `context-<slot>` variant exists for every slot the pool can hand out, so a cartography run borrows shared capacity instead of a dedicated model.
334
335
  - [ ] Given a user config with no `instructions` array, When the config hook runs, Then the standing context-map rule is injected, naming all five map files, telling the agent to check with `ls` and never the `glob` tool, to read only the file relevant to the task, and to start from the canonical entry points the map lists.
335
- - [ ] Given the injected `context-manager`, When its prompt is inspected, Then it names the five files it owns, requires a short "Start here" list of canonical files per significant subsystem, and forbids file inventories — the map answers "what should I look at first?", never "tell me everything".
336
+ - [ ] Given the injected `context`, When its prompt is inspected, Then it names the five files it owns, requires a short "Start here" list of canonical files per significant subsystem, and forbids file inventories — the map answers "what should I look at first?", never "tell me everything".
336
337
 
337
338
  ### US-20: The map stays current without being asked
338
339
  **As a** user, **I want** the map refreshed automatically after a session that edited code, **so that** agents are never misled by stale documentation.
339
340
 
340
341
  **Story ID(s) for tests:** US-20
341
342
  **Acceptance criteria:**
342
- - [ ] Given a file inside the project was edited, When the root session next goes idle, Then a `context-manager` session is created and prompted with the edited paths listed, rather than nothing happening.
343
+ - [ ] Given a file inside the project was edited, When the root session next goes idle, Then a `context` session is created and prompted with the edited paths listed, rather than nothing happening.
343
344
  - [ ] Given `.opencode/context/` does not exist, When the automatic run fires, Then it is told to bootstrap the map; given that the directory exists, Then it is told to make an incremental update.
344
345
  - [ ] Given an idle event with no pending edit, or one from a subagent session, or one following an edit inside the map itself or inside a build directory, Then no cartographer is spawned.
345
346
  - [ ] Given an automatic run borrowed a pool slot, When it completes, Then the slot is released and the cartographer session going idle does not spawn a second run.
@@ -351,15 +352,15 @@ makes agents read it before they explore, then keeps it current on its own.
351
352
 
352
353
  **Story ID(s) for tests:** US-21
353
354
 
354
- Enforcement is split deliberately, and the split matters: **what the permission table enforces** is that `context-manager` can write the map and that *no other agent can*; **what the prompt enforces** is that every other agent knows to report map-relevant findings instead of making them. The permission half is the load-bearing one — it holds regardless of what the model decides to do — but it cannot tell an agent what it *should* have done, so the instruction is not redundant and is not going away.
355
+ Enforcement is split deliberately, and the split matters: **what the permission table enforces** is that `context` can write the map and that *no other agent can*; **what the prompt enforces** is that every other agent knows to report map-relevant findings instead of making them. The permission half is the load-bearing one — it holds regardless of what the model decides to do — but it cannot tell an agent what it *should* have done, so the instruction is not redundant and is not going away.
355
356
 
356
357
  **Acceptance criteria:**
357
- - [ ] Given the injected agent set, When every agent's `edit` permission is evaluated against a path inside `.opencode/context/`, Then `context-manager` is the only agent whose winning rule for that path is `allow`, and every agent that may otherwise write files carries a `deny` on both spellings of the map path — so the map is closed to non-cartographers by permission, not only by instruction.
358
+ - [ ] Given the injected agent set, When every agent's `edit` permission is evaluated against a path inside `.opencode/context/`, Then `context` is the only agent whose winning rule for that path is `allow`, and every agent that may otherwise write files carries a `deny` on both spellings of the map path — so the map is closed to non-cartographers by permission, not only by instruction.
358
359
  - [ ] Given an agent's `edit` rules are evaluated the way opencode evaluates them, When the narrow map `deny` is declared before a broad `"*": "allow"`, Then the broad rule wins and the agent is reported as a second writer; so the deny is required to be the LAST entry, and the shipped table must be declared in that order.
359
- - [ ] Given the `context-manager` permission rule, When it is evaluated for a normally-rooted project and for a project rooted at `/`, Then both spellings of the map path are allowed, because opencode evaluates file permissions relative to the project root.
360
+ - [ ] Given the `context` permission rule, When it is evaluated for a normally-rooted project and for a project rooted at `/`, Then both spellings of the map path are allowed, because opencode evaluates file permissions relative to the project root.
360
361
  - [ ] Given a config whose one-writer rule allows neither spelling, When the writer rule is verified, Then it reports which rules it saw, that the map is not writable, and that both forms are required — instead of leaving a map that silently never updates.
361
362
  - [ ] Given a config in which a non-cartographer agent would be granted the map, When the writer rule is verified, Then it reports that agent by name together with the rule that wins for it, and the report is silent once the deny is narrowed and placed last.
362
- - [ ] Given an agent other than `context-manager` finishes work, When the standing rule reaches it, Then it is told never to edit the map itself and to report what belongs in it instead, so that a denied agent knows what to do rather than only that it was refused.
363
+ - [ ] Given an agent other than `context` finishes work, When the standing rule reaches it, Then it is told never to edit the map itself and to report what belongs in it instead, so that a denied agent knows what to do rather than only that it was refused.
363
364
  - [ ] Given `edit` is denied for the map, When the same agents read the map, Then reading is unaffected: the rule narrows the `edit` tool only, so the map stays readable by exactly the agents that are told to check it.
364
365
 
365
366
  ### US-22: On-demand map operations
@@ -367,7 +368,7 @@ Enforcement is split deliberately, and the split matters: **what the permission
367
368
 
368
369
  **Story ID(s) for tests:** US-22
369
370
  **Acceptance criteria:**
370
- - [ ] Given a config that defines none of LaCode's commands, When the config hook runs, Then `/context-init`, `/context-update` and `/context-review` are injected, each targeting `context-manager` as a subtask.
371
+ - [ ] Given a config that defines none of LaCode's commands, When the config hook runs, Then `/context-init`, `/context-update` and `/context-review` are injected, each targeting `context` as a subtask.
371
372
  - [ ] Given an injected command template, When opencode substitutes `$ARGUMENTS`, Then `/context-update` carries the user's focus text in the placeholder's place and the procedure around it is unchanged.
372
373
  - [ ] Given the three commands have been injected, When their templates are inspected, Then each names the procedure it delegates to (bootstrap, incremental update, or review) and none carries an unsubstituted placeholder other than `/context-update`'s single `$ARGUMENTS`.
373
374
  - [ ] Given the user's config already defines one of the three commands, When the config hook runs, Then that command is left byte-identical while the other two are still added.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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",
@@ -4,7 +4,7 @@
4
4
  "plugin options -> ~/.config/lacode/pool.json -> this file -> a built-in fallback.",
5
5
  "",
6
6
  "src/lib/pool.ts reads it at config time and rewrites every pooled variant agent",
7
- "(explore-fast-N, implement-fast-N, context-manager-N) plus the provider whitelists",
7
+ "(explore-N, implement-N, context-N) plus the provider whitelists",
8
8
  "to match, so no model is written down next to an agent anywhere.",
9
9
  "Slot N is the same model for every base agent, which is what makes load shared.",
10
10
  "",