@lawaty/lacode 1.3.0-beta.7 → 1.3.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ All notable changes to LaCode are recorded here. The format follows
7
7
  Versions come from git release commits; the npm version is bumped in the same
8
8
  commit as the changelog entry it describes.
9
9
 
10
+ ## [Unreleased]
11
+
12
+ ## [1.3.0-beta.8] — a blocked child is never silent
13
+
14
+ ### Added
15
+
16
+ - **Provider compatibility: object-shaped reasoning no longer crashes a spawn.** OpenAI-compatible gateways that stream `choices[].delta.reasoning` as an object (`{ encrypted_content, content, summary }`) are normalized to the string opencode's chunk schema expects, so the spawn survives the first reasoning chunk instead of dying with `invalid_union`.
17
+ - **Pool: the rate-limit-reset notice is on by default only for `opencode` and `opencode-go` slots.** Every other provider stays quiet unless its slot sets `notify:on` on the `pool.txt` line (or `"notify": true` in `pool.json`); `notify:off` silences an `opencode`/`opencode-go` slot, and a model listed on several lines is announced if any of its slots is on (US-34).
18
+
19
+ ### Changed
20
+
21
+ - **Build agent prompt: delegation is now the default.** The root agent's prompt carries the delegate-first gate and hard rules requiring a `task` spawn before repository inspection or edits, with bash reserved for applying and verifying a subagent's result.
22
+ - **`score-models`: probe `opencode`/`opencode-go` through a local `opencode serve --pure` session** instead of direct HTTP, and restrict candidates to concrete text chat/completion models (media generators, safety classifiers and OpenRouter meta-routers are skipped).
23
+ - **Cancelled tasks now surface their reason via a toast** when the task card cannot be rewritten (no `part` sub-client or an unauthenticated PATCH), so the user still sees why the slot was aborted.
24
+ - **Publishing now syncs the config tree too.** `postpublish` runs `scripts/sync-hosts.mjs` and then the repo's `bin/oc-sync`, so a release deploys both the registry package and the working config without a second manual step.
25
+
26
+ ### Fixed
27
+
28
+ - **A subagent blocked on a permission or question now notifies.** Every subagent outcome used to be suppressed, so a permission prompt in another project could be completely silent; `done` and `error` remain suppressed (US-12, US-25).
29
+
30
+ ### Documented
31
+
32
+ - **README / CONFIGURATION:** per-slot `notify` semantics and provider-based defaults (US-34).
33
+ - **USER-STORIES:** US-34 added; US-12 and US-25 now record that a blocked subagent still notifies.
34
+
10
35
  ## [1.3.0-beta.7] — only a rate-limit reset speaks
11
36
 
12
37
  ### Changed
@@ -1072,7 +1097,8 @@ experimental; `latest` remains 0.4.2.
1072
1097
 
1073
1098
  - First published release: pooled subagents over a shared set of models.
1074
1099
 
1075
- [Unreleased]: https://github.com/lawaty/lacode/compare/v1.2.0...HEAD
1100
+ [Unreleased]: https://github.com/lawaty/lacode/compare/v1.3.0-beta.8...HEAD
1101
+ [1.3.0-beta.8]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.8
1076
1102
  [1.3.0-beta.0]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.0
1077
1103
  [1.2.0]: https://github.com/lawaty/lacode/compare/v1.1.0...v1.2.0
1078
1104
  [1.1.0]: https://github.com/lawaty/lacode/compare/v1.0.0...v1.1.0
package/README.md CHANGED
@@ -76,6 +76,10 @@ opencode agent list | grep -E '^(explore|implement|context)'
76
76
  You should see `explore`, `implement` and `context`, each with hidden `-1`..`-4`
77
77
  pool variants alongside. That is the whole install, and you are done.
78
78
 
79
+ Each pool slot controls the rate-limit-reset desktop notice: it is on by
80
+ default for `opencode` and `opencode-go` slots, and other providers need
81
+ `notify:on` in `pool.txt` or `"notify": true` in `pool.json` to opt in.
82
+
79
83
  **If that prints nothing, the plugin did not load** — and opencode will not tell
80
84
  you: it starts normally, logs nothing, and the agents are simply absent. A
81
85
  plugin that fails to load is silent by design of the host, so this check is the
package/dist/agents.js CHANGED
@@ -441,6 +441,48 @@ or search the web. Those tools are denied by configuration, not by convention.
441
441
  Every question of the form "what does this file do", "where is X handled", or
442
442
  "what does this error come from" belongs to an explorer, not to you.
443
443
 
444
+ # Delegate first \u2014 this is the default
445
+
446
+ Delegation is the default action, not an optimisation you reach for once a task
447
+ looks big. Before you call ANY tool on a turn that touches the repository,
448
+ apply this gate:
449
+
450
+ 1. **Finding something out?** (read a file, grep, glob, list, inspect git
451
+ history, understand a call site, trace an error) \u2192 spawn \`@explore\`.
452
+ 2. **Changing code?** \u2192 spawn \`@implement\` with one decided unit.
453
+ 3. **Applying a patch or output a subagent returned, or verifying it?** (run
454
+ tests, typecheck, lint, build, git) \u2192 you may use \`edit\`/\`write\`/\`bash\`.
455
+
456
+ Only case 3 is yours. Cases 1 and 2 are delegations, and on a normal task the
457
+ very first action of the turn is a \`task\` spawn. A turn that inspects or
458
+ changes repository code without spawning first is a mistake, not a shortcut.
459
+
460
+ If you notice you have already inspected or edited directly: stop, say so, and
461
+ hand the remaining work to the right base. Do not keep going because the direct
462
+ call already happened, and do not "finish quickly" what should have been
463
+ delegated.
464
+
465
+ The direct call is tempting because it saves one round trip. It is also the
466
+ failure mode: it fills your context with source the user pays for again on every
467
+ later turn. A delegated read returns a conclusion; a direct read returns a
468
+ transcript.
469
+
470
+ # Hard rules
471
+
472
+ - The first repository action on a task is a \`task\` spawn \u2014 not \`read\`, \`grep\`,
473
+ \`glob\`, \`list\`, \`bash\`, \`edit\` or \`write\`.
474
+ - Never use bash to look. \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, \`grep\`, \`rg\`,
475
+ \`find\`, \`ls\`, \`tree\`, \`wc\` and friends are exploration, and exploration
476
+ belongs to \`@explore\`. Running them yourself is the exact behaviour this role
477
+ exists to prevent.
478
+ - Never make a code change yourself when \`@implement\` can make it. "It is only
479
+ one line" is not an exception; one line is the ideal size for a small ask.
480
+ - Every direct inspection or edit must be either a returned result you are
481
+ applying or verifying, or it must be named and justified in your next
482
+ message. An unexplained one is a violation of this rule.
483
+ - Subagents are not bound by this rule. \`@explore\`, \`@implement\` and \`@context\`
484
+ do the reading, grepping and mechanical edits.
485
+
444
486
  # Delegation
445
487
 
446
488
  - **Investigation \u2192 @explore.** Give it specific, focused instructions
@@ -470,17 +512,16 @@ to do the work yourself, and not a reason to stop and ask.
470
512
  the same base again. If a spawn fails, do not switch to a different name \u2014 a
471
513
  failure means the pool has a routing problem, not that you picked the wrong one.
472
514
 
473
- Retry is always available
474
- and never wrong, which is why it is the default. But
515
+ Retry is always available and never wrong, which is why it is the default. But
475
516
  "never wrong" is not "sufficient": looping silently against a pool with every
476
517
  model cooling goes nowhere and burns the slot doing it. So separate the two
477
518
  failures. **Transient** \u2014 a cooldown, a rate limit, one slot at its ceiling \u2014
478
519
  means retry. **Persistent or structural** \u2014 every slot cooling, the same spawn
479
520
  failing repeatedly with the same error, a malformed model id \u2014 means stop
480
- retrying and *report* it: say what failed (slot, model, error) and continue
481
- with whatever else the task needs, or ask the user. Surface the failure; do not
482
- spin on it and do not let it end the whole task. Do not escalate to \`expert\` as
483
- a workaround; it is opt-in by the user.
521
+ retrying and *report* it: say what failed (slot, model, error) and continue with
522
+ whatever else the task needs, or ask the user. Surface the failure; do not spin
523
+ on it and do not let it end the whole task. Do not escalate to \`expert\` as a
524
+ workaround; it is opt-in by the user.
484
525
 
485
526
  **Never fall back to reading, grepping, exploring or implementing on your own.**
486
527
  Do not "just quickly" inspect a file because a spawn failed. A failed spawn is
@@ -514,7 +555,8 @@ project. A cheap agent handed it will improvise a design, skip the verification
514
555
  you never spelled out, and return something you cannot trust.
515
556
 
516
557
  When the work does not divide into small asks it is not implementation work
517
- yet \u2014 send it to \`@plan\`, or keep it yourself.
558
+ yet \u2014 use \`@explore\` to break it into a sequence of small asks, or ask the user
559
+ to run \`@plan\`. Do not do the large work yourself.
518
560
 
519
561
  Split into one ask per task, spawn independent asks together in a single
520
562
  message so they run in parallel, and sequence only what truly depends on what.
@@ -531,6 +573,9 @@ Do NOT use bash to inspect the codebase. \`cat\`, \`head\`, \`tail\`, \`sed\`, \
531
573
  \`grep\`, \`find\`, \`ls\`, \`tree\` and friends are exploration, and exploration goes
532
574
  to @explore. Bash is for executing and verifying, not for looking.
533
575
 
576
+ If a bash command does not run a test, typecheck, linter, build, or git
577
+ operation, assume it is exploration and delegate it.
578
+
534
579
  # Repository context
535
580
 
536
581
  Before delegating a non-trivial task, tell @explore to check
@@ -553,7 +598,8 @@ report instead of editing it yourself.
553
598
 
554
599
  When you report back, say which explorer answered which question and name the
555
600
  context file(s) that actually shaped the work (or state plainly that the map was
556
- missing). Never paste context file contents into the report.`,
601
+ missing). Never paste context file contents into the report.
602
+ `,
557
603
  permission: {
558
604
  "read": "deny",
559
605
  "glob": "deny",
package/dist/index.js CHANGED
@@ -39,6 +39,17 @@ var UNAVAILABLE_REASON = "endpoint-unavailable";
39
39
  var MAX_WEIGHT = 100;
40
40
  var slotCeiling = (slot) => slot.weight;
41
41
  var slotBucket = (slot) => slot.bucket ?? slot.model;
42
+ var NOTIFY_DEFAULT_ON_PROVIDERS = /* @__PURE__ */ new Set(["opencode", "opencode-go"]);
43
+ var notifyDefault = (model) => {
44
+ const slash = model.indexOf("/");
45
+ const provider = slash < 0 ? model : model.slice(0, slash);
46
+ return NOTIFY_DEFAULT_ON_PROVIDERS.has(provider);
47
+ };
48
+ var slotNotify = (slot) => typeof slot.notify === "boolean" ? slot.notify : notifyDefault(slot.model);
49
+ var announcesReset = (slots, model) => {
50
+ const matching = slots.filter((slot) => slot.model === model);
51
+ return matching.length > 0 ? matching.some((slot) => slotNotify(slot)) : notifyDefault(model);
52
+ };
42
53
  var SLOTS_WARN_AT = 64;
43
54
  var COOLDOWN_BASE_MS = 60 * 1e3;
44
55
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
@@ -67,6 +78,19 @@ var FALLBACK_SLOTS = [
67
78
  { index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
68
79
  { index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
69
80
  ];
81
+ var coerceNotify = (raw, at, model, problems) => {
82
+ if (raw === void 0) return notifyDefault(model);
83
+ if (typeof raw === "boolean") return raw;
84
+ if (typeof raw === "string") {
85
+ const s = raw.trim().toLowerCase();
86
+ if (s === "true" || s === "1" || s === "on" || s === "yes") return true;
87
+ if (s === "false" || s === "0" || s === "off" || s === "no") return false;
88
+ problems.push(`${at} (${model}): notify must be a boolean or one of on/off, got ${JSON.stringify(raw)}; keeping the provider default`);
89
+ return notifyDefault(model);
90
+ }
91
+ problems.push(`${at} (${model}): notify must be a boolean or one of on/off, got ${JSON.stringify(raw)}; keeping the provider default`);
92
+ return notifyDefault(model);
93
+ };
70
94
  function parseSlots(parsed, origin) {
71
95
  const problems = [];
72
96
  const entries = parsed?.slots;
@@ -84,7 +108,8 @@ function parseSlots(parsed, origin) {
84
108
  if (rawBucket !== void 0 && !bucket) {
85
109
  problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
86
110
  }
87
- slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {} });
111
+ const notify = coerceNotify(entry.notify, at, model, problems);
112
+ slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify });
88
113
  });
89
114
  if (slots.length > SLOTS_WARN_AT) {
90
115
  problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
@@ -103,14 +128,19 @@ function parseTextSlots(text, origin) {
103
128
  const [model, ...rest] = line.split(/\s+/);
104
129
  let weight;
105
130
  let bucket;
131
+ let notify;
106
132
  for (const token of rest) {
133
+ if (token.startsWith("notify:")) {
134
+ if (notify === void 0) notify = token.slice("notify:".length);
135
+ continue;
136
+ }
107
137
  if (token.startsWith("b:")) {
108
138
  if (bucket === void 0) bucket = token.slice(2);
109
139
  } else if (weight === void 0) {
110
140
  weight = token;
111
141
  }
112
142
  }
113
- entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket } });
143
+ entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify } });
114
144
  }
115
145
  return parseSlots({ slots: entries }, origin);
116
146
  }
@@ -319,7 +349,7 @@ function ensureSlots(s, onWarn) {
319
349
  for (const problem of problems) onWarn(problem);
320
350
  return slots;
321
351
  }
322
- var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}`).join("|")}`;
352
+ var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}:${slotNotify(slot) ? "1" : "0"}`).join("|")}`;
323
353
  function reloadSlots(s, onWarn) {
324
354
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
325
355
  const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
@@ -1238,6 +1268,48 @@ or search the web. Those tools are denied by configuration, not by convention.
1238
1268
  Every question of the form "what does this file do", "where is X handled", or
1239
1269
  "what does this error come from" belongs to an explorer, not to you.
1240
1270
 
1271
+ # Delegate first \u2014 this is the default
1272
+
1273
+ Delegation is the default action, not an optimisation you reach for once a task
1274
+ looks big. Before you call ANY tool on a turn that touches the repository,
1275
+ apply this gate:
1276
+
1277
+ 1. **Finding something out?** (read a file, grep, glob, list, inspect git
1278
+ history, understand a call site, trace an error) \u2192 spawn \`@explore\`.
1279
+ 2. **Changing code?** \u2192 spawn \`@implement\` with one decided unit.
1280
+ 3. **Applying a patch or output a subagent returned, or verifying it?** (run
1281
+ tests, typecheck, lint, build, git) \u2192 you may use \`edit\`/\`write\`/\`bash\`.
1282
+
1283
+ Only case 3 is yours. Cases 1 and 2 are delegations, and on a normal task the
1284
+ very first action of the turn is a \`task\` spawn. A turn that inspects or
1285
+ changes repository code without spawning first is a mistake, not a shortcut.
1286
+
1287
+ If you notice you have already inspected or edited directly: stop, say so, and
1288
+ hand the remaining work to the right base. Do not keep going because the direct
1289
+ call already happened, and do not "finish quickly" what should have been
1290
+ delegated.
1291
+
1292
+ The direct call is tempting because it saves one round trip. It is also the
1293
+ failure mode: it fills your context with source the user pays for again on every
1294
+ later turn. A delegated read returns a conclusion; a direct read returns a
1295
+ transcript.
1296
+
1297
+ # Hard rules
1298
+
1299
+ - The first repository action on a task is a \`task\` spawn \u2014 not \`read\`, \`grep\`,
1300
+ \`glob\`, \`list\`, \`bash\`, \`edit\` or \`write\`.
1301
+ - Never use bash to look. \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, \`grep\`, \`rg\`,
1302
+ \`find\`, \`ls\`, \`tree\`, \`wc\` and friends are exploration, and exploration
1303
+ belongs to \`@explore\`. Running them yourself is the exact behaviour this role
1304
+ exists to prevent.
1305
+ - Never make a code change yourself when \`@implement\` can make it. "It is only
1306
+ one line" is not an exception; one line is the ideal size for a small ask.
1307
+ - Every direct inspection or edit must be either a returned result you are
1308
+ applying or verifying, or it must be named and justified in your next
1309
+ message. An unexplained one is a violation of this rule.
1310
+ - Subagents are not bound by this rule. \`@explore\`, \`@implement\` and \`@context\`
1311
+ do the reading, grepping and mechanical edits.
1312
+
1241
1313
  # Delegation
1242
1314
 
1243
1315
  - **Investigation \u2192 @explore.** Give it specific, focused instructions
@@ -1267,17 +1339,16 @@ to do the work yourself, and not a reason to stop and ask.
1267
1339
  the same base again. If a spawn fails, do not switch to a different name \u2014 a
1268
1340
  failure means the pool has a routing problem, not that you picked the wrong one.
1269
1341
 
1270
- Retry is always available
1271
- and never wrong, which is why it is the default. But
1342
+ Retry is always available and never wrong, which is why it is the default. But
1272
1343
  "never wrong" is not "sufficient": looping silently against a pool with every
1273
1344
  model cooling goes nowhere and burns the slot doing it. So separate the two
1274
1345
  failures. **Transient** \u2014 a cooldown, a rate limit, one slot at its ceiling \u2014
1275
1346
  means retry. **Persistent or structural** \u2014 every slot cooling, the same spawn
1276
1347
  failing repeatedly with the same error, a malformed model id \u2014 means stop
1277
- retrying and *report* it: say what failed (slot, model, error) and continue
1278
- with whatever else the task needs, or ask the user. Surface the failure; do not
1279
- spin on it and do not let it end the whole task. Do not escalate to \`expert\` as
1280
- a workaround; it is opt-in by the user.
1348
+ retrying and *report* it: say what failed (slot, model, error) and continue with
1349
+ whatever else the task needs, or ask the user. Surface the failure; do not spin
1350
+ on it and do not let it end the whole task. Do not escalate to \`expert\` as a
1351
+ workaround; it is opt-in by the user.
1281
1352
 
1282
1353
  **Never fall back to reading, grepping, exploring or implementing on your own.**
1283
1354
  Do not "just quickly" inspect a file because a spawn failed. A failed spawn is
@@ -1311,7 +1382,8 @@ project. A cheap agent handed it will improvise a design, skip the verification
1311
1382
  you never spelled out, and return something you cannot trust.
1312
1383
 
1313
1384
  When the work does not divide into small asks it is not implementation work
1314
- yet \u2014 send it to \`@plan\`, or keep it yourself.
1385
+ yet \u2014 use \`@explore\` to break it into a sequence of small asks, or ask the user
1386
+ to run \`@plan\`. Do not do the large work yourself.
1315
1387
 
1316
1388
  Split into one ask per task, spawn independent asks together in a single
1317
1389
  message so they run in parallel, and sequence only what truly depends on what.
@@ -1328,6 +1400,9 @@ Do NOT use bash to inspect the codebase. \`cat\`, \`head\`, \`tail\`, \`sed\`, \
1328
1400
  \`grep\`, \`find\`, \`ls\`, \`tree\` and friends are exploration, and exploration goes
1329
1401
  to @explore. Bash is for executing and verifying, not for looking.
1330
1402
 
1403
+ If a bash command does not run a test, typecheck, linter, build, or git
1404
+ operation, assume it is exploration and delegate it.
1405
+
1331
1406
  # Repository context
1332
1407
 
1333
1408
  Before delegating a non-trivial task, tell @explore to check
@@ -1350,7 +1425,8 @@ report instead of editing it yourself.
1350
1425
 
1351
1426
  When you report back, say which explorer answered which question and name the
1352
1427
  context file(s) that actually shaped the work (or state plainly that the map was
1353
- missing). Never paste context file contents into the report.`,
1428
+ missing). Never paste context file contents into the report.
1429
+ `,
1354
1430
  permission: {
1355
1431
  "read": "deny",
1356
1432
  "glob": "deny",
@@ -2568,7 +2644,9 @@ var safeTitle = (raw, fallback = "session") => {
2568
2644
  var shouldNotify = (input) => {
2569
2645
  const { info, outcome, detail } = input;
2570
2646
  if (!info?.id) return { notify: false, reason: "no session info" };
2571
- if (info.parentID) return { notify: false, reason: "subagent" };
2647
+ if (info.parentID && outcome !== "permission" && outcome !== "question") {
2648
+ return { notify: false, reason: "subagent" };
2649
+ }
2572
2650
  const sessionTitle = safeTitle(info.title);
2573
2651
  if (INTERNAL_TITLE_PREFIXES.some((prefix) => sessionTitle.startsWith(prefix))) {
2574
2652
  return { notify: false, reason: "internal session" };
@@ -3033,6 +3111,18 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3033
3111
  const pendingCancelledParts = /* @__PURE__ */ new Map();
3034
3112
  const patchedCancelledParts = /* @__PURE__ */ new Set();
3035
3113
  const cancelledPrefix = "Task Cancelled by LaCode:";
3114
+ const showAbortToast = async (report) => {
3115
+ const toast2 = client.tui?.showToast;
3116
+ if (typeof toast2 !== "function") return;
3117
+ try {
3118
+ const args = {
3119
+ body: { message: `${cancelledPrefix} ${report.message}`, variant: "warning", duration: 8e3 }
3120
+ };
3121
+ if (directory) args.query = { directory };
3122
+ await toast2(args);
3123
+ } catch {
3124
+ }
3125
+ };
3036
3126
  const patchCancelledPart = async (part, key, report) => {
3037
3127
  if (patchedCancelledParts.has(key)) return;
3038
3128
  patchedCancelledParts.add(key);
@@ -3082,6 +3172,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3082
3172
  session: part.sessionID,
3083
3173
  callID: part.callID
3084
3174
  });
3175
+ await showAbortToast(report);
3085
3176
  }
3086
3177
  };
3087
3178
  const noteAbort = async (key, report) => {
@@ -3302,7 +3393,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3302
3393
  };
3303
3394
  const resetMessage = (models) => models.length === 1 ? `${models[0]} is available again \u2014 the rate limit has reset.` : `${models.length} slots are available again \u2014 their rate limits have reset: ${models.join(", ")}.`;
3304
3395
  const announceReady = (models) => {
3305
- const fresh = models.filter((model) => claimDesktopNotice(model));
3396
+ const wanted = models.filter((model) => announcesReset(s.slots, model));
3397
+ const fresh = wanted.filter((model) => claimDesktopNotice(model));
3306
3398
  if (fresh.length === 0) return;
3307
3399
  try {
3308
3400
  notify("LaCode: rate limit reset", resetMessage(fresh));
@@ -6427,6 +6519,147 @@ var omniRouteHooks = async (input, options, deps = {}) => {
6427
6519
  };
6428
6520
  };
6429
6521
 
6522
+ // src/lib/provider-compat.ts
6523
+ var WRAPPED = /* @__PURE__ */ Symbol.for("lacode.providerCompat");
6524
+ var REASONING_FIELDS = ["reasoning", "reasoning_content"];
6525
+ var asText = (value) => typeof value === "string" ? value : "";
6526
+ var itemText = (item) => {
6527
+ if (typeof item === "string") return item;
6528
+ if (item && typeof item === "object") return asText(item.text);
6529
+ return "";
6530
+ };
6531
+ function reasoningText(value) {
6532
+ if (typeof value === "string") return value;
6533
+ if (!value || typeof value !== "object") return "";
6534
+ const source = value;
6535
+ const direct = asText(source.text);
6536
+ if (direct) return direct;
6537
+ const summary = source.summary;
6538
+ if (typeof summary === "string") return summary;
6539
+ if (Array.isArray(summary)) return summary.map(itemText).join("");
6540
+ if (summary && typeof summary === "object") return asText(summary.text);
6541
+ const content = source.content;
6542
+ if (typeof content === "string") return content;
6543
+ if (Array.isArray(content)) return content.map(itemText).join("");
6544
+ return "";
6545
+ }
6546
+ function normalizeChunk(value) {
6547
+ if (!value || typeof value !== "object") return false;
6548
+ const choices = value.choices;
6549
+ if (!Array.isArray(choices)) return false;
6550
+ let changed = false;
6551
+ for (const choice of choices) {
6552
+ if (!choice || typeof choice !== "object") continue;
6553
+ for (const key of ["delta", "message"]) {
6554
+ const holder = choice[key];
6555
+ if (!holder || typeof holder !== "object") continue;
6556
+ for (const field of REASONING_FIELDS) {
6557
+ const current = holder[field];
6558
+ if (current === null || typeof current !== "object") continue;
6559
+ holder[field] = reasoningText(current);
6560
+ changed = true;
6561
+ }
6562
+ }
6563
+ }
6564
+ return changed;
6565
+ }
6566
+ function normalizeSseLine(line) {
6567
+ const trimmed = line.trim();
6568
+ if (!trimmed.startsWith("data:")) return line;
6569
+ const payload = trimmed.slice("data:".length).trim();
6570
+ if (!payload || payload === "[DONE]") return line;
6571
+ let parsed;
6572
+ try {
6573
+ parsed = JSON.parse(payload);
6574
+ } catch {
6575
+ return line;
6576
+ }
6577
+ if (!normalizeChunk(parsed)) return line;
6578
+ const newline = line.endsWith("\n") ? "\n" : "";
6579
+ return `data: ${JSON.stringify(parsed)}${newline}`;
6580
+ }
6581
+ function createChunkNormalizer() {
6582
+ const decoder = new TextDecoder();
6583
+ const encoder = new TextEncoder();
6584
+ let buffered = "";
6585
+ return new TransformStream({
6586
+ transform(chunk, controller) {
6587
+ buffered += decoder.decode(chunk, { stream: true });
6588
+ let newline = buffered.indexOf("\n");
6589
+ while (newline !== -1) {
6590
+ const line = buffered.slice(0, newline + 1);
6591
+ buffered = buffered.slice(newline + 1);
6592
+ controller.enqueue(encoder.encode(normalizeSseLine(line)));
6593
+ newline = buffered.indexOf("\n");
6594
+ }
6595
+ },
6596
+ flush(controller) {
6597
+ buffered += decoder.decode();
6598
+ if (buffered) controller.enqueue(encoder.encode(normalizeSseLine(buffered)));
6599
+ }
6600
+ });
6601
+ }
6602
+ function wrapFetch(original) {
6603
+ const wrapped = async (input, init2) => {
6604
+ const response = await original(input, init2);
6605
+ try {
6606
+ const contentType = response.headers.get("content-type") ?? "";
6607
+ if (!contentType.includes("text/event-stream") || !response.body) return response;
6608
+ const headers = new Headers(response.headers);
6609
+ headers.delete("content-length");
6610
+ headers.delete("content-encoding");
6611
+ headers.delete("transfer-encoding");
6612
+ return new Response(response.body.pipeThrough(createChunkNormalizer()), {
6613
+ status: response.status,
6614
+ statusText: response.statusText,
6615
+ headers
6616
+ });
6617
+ } catch {
6618
+ return response;
6619
+ }
6620
+ };
6621
+ wrapped[WRAPPED] = true;
6622
+ return wrapped;
6623
+ }
6624
+ function installProviderCompat() {
6625
+ const target = globalThis.fetch;
6626
+ if (typeof target !== "function") return () => {
6627
+ };
6628
+ if (target[WRAPPED]) return () => {
6629
+ };
6630
+ const wrapped = wrapFetch(target);
6631
+ globalThis.fetch = wrapped;
6632
+ return () => {
6633
+ if (globalThis.fetch === wrapped) {
6634
+ ;
6635
+ globalThis.fetch = target;
6636
+ }
6637
+ };
6638
+ }
6639
+
6640
+ // src/plugins/provider-compat.ts
6641
+ var SERVICE5 = "provider-compat";
6642
+ var providerCompatHooks = async (input) => {
6643
+ const log = (level, message) => input.client.app.log({ body: { service: SERVICE5, level, message } }).catch(() => {
6644
+ });
6645
+ let restore = () => {
6646
+ };
6647
+ try {
6648
+ restore = installProviderCompat();
6649
+ await log("debug", "normalizing object-shaped reasoning in provider streams");
6650
+ } catch (error) {
6651
+ await log("warn", `provider compat unavailable: ${String(error)}`);
6652
+ }
6653
+ return {
6654
+ dispose: async () => {
6655
+ try {
6656
+ restore();
6657
+ } catch {
6658
+ }
6659
+ }
6660
+ };
6661
+ };
6662
+
6430
6663
  // src/plugins/selfupdate.ts
6431
6664
  async function selfUpdateHooks(input, options) {
6432
6665
  const opts = options ?? {};
@@ -6819,9 +7052,9 @@ function timelineHooks(input, options) {
6819
7052
  const opts = options ?? {};
6820
7053
  const config = typeof opts.timeline === "object" && opts.timeline !== null ? opts.timeline : void 0;
6821
7054
  const disabled = opts.timeline === false || config?.enabled === false || process.env.LACODE_TIMELINE === "0";
6822
- const SERVICE5 = "timeline";
7055
+ const SERVICE6 = "timeline";
6823
7056
  const poolState = state(options);
6824
- const log = (level, message, extra) => client.app.log({ body: { service: SERVICE5, level, message, extra } }).catch(() => {
7057
+ const log = (level, message, extra) => client.app.log({ body: { service: SERVICE6, level, message, extra } }).catch(() => {
6825
7058
  });
6826
7059
  const debug = (...args) => {
6827
7060
  if (process.env.LACODE_TIMELINE_DEBUG === "1") console.error("[timeline]", ...args);
@@ -7246,6 +7479,7 @@ var LaCode = (async (input, options) => {
7246
7479
  const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
7247
7480
  const feedback = await settled("feedback", () => feedbackHooks(input, options)) ?? inertHooks;
7248
7481
  const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
7482
+ const compat = await settled("provider-compat", () => providerCompatHooks(input)) ?? inertHooks;
7249
7483
  const imagepaste = await settled("image-paste", () => imagePasteHooks(input, opts.imagePaste)) ?? inertHooks;
7250
7484
  const timeline = await settled("timeline", () => timelineHooks(input, options)) ?? inertHooks;
7251
7485
  const config = async (cfg) => {
@@ -7292,6 +7526,7 @@ var LaCode = (async (input, options) => {
7292
7526
  await selfupdate.dispose?.();
7293
7527
  await feedback.dispose?.();
7294
7528
  await omniroute.dispose?.();
7529
+ await compat.dispose?.();
7295
7530
  await imagepaste.dispose?.();
7296
7531
  await timeline.dispose?.();
7297
7532
  },
package/dist/pool.js CHANGED
@@ -39,6 +39,17 @@ var UNAVAILABLE_REASON = "endpoint-unavailable";
39
39
  var MAX_WEIGHT = 100;
40
40
  var slotCeiling = (slot) => slot.weight;
41
41
  var slotBucket = (slot) => slot.bucket ?? slot.model;
42
+ var NOTIFY_DEFAULT_ON_PROVIDERS = /* @__PURE__ */ new Set(["opencode", "opencode-go"]);
43
+ var notifyDefault = (model) => {
44
+ const slash = model.indexOf("/");
45
+ const provider = slash < 0 ? model : model.slice(0, slash);
46
+ return NOTIFY_DEFAULT_ON_PROVIDERS.has(provider);
47
+ };
48
+ var slotNotify = (slot) => typeof slot.notify === "boolean" ? slot.notify : notifyDefault(slot.model);
49
+ var announcesReset = (slots, model) => {
50
+ const matching = slots.filter((slot) => slot.model === model);
51
+ return matching.length > 0 ? matching.some((slot) => slotNotify(slot)) : notifyDefault(model);
52
+ };
42
53
  var SLOTS_WARN_AT = 64;
43
54
  var COOLDOWN_BASE_MS = 60 * 1e3;
44
55
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
@@ -67,6 +78,19 @@ var FALLBACK_SLOTS = [
67
78
  { index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
68
79
  { index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
69
80
  ];
81
+ var coerceNotify = (raw, at, model, problems) => {
82
+ if (raw === void 0) return notifyDefault(model);
83
+ if (typeof raw === "boolean") return raw;
84
+ if (typeof raw === "string") {
85
+ const s = raw.trim().toLowerCase();
86
+ if (s === "true" || s === "1" || s === "on" || s === "yes") return true;
87
+ if (s === "false" || s === "0" || s === "off" || s === "no") return false;
88
+ problems.push(`${at} (${model}): notify must be a boolean or one of on/off, got ${JSON.stringify(raw)}; keeping the provider default`);
89
+ return notifyDefault(model);
90
+ }
91
+ problems.push(`${at} (${model}): notify must be a boolean or one of on/off, got ${JSON.stringify(raw)}; keeping the provider default`);
92
+ return notifyDefault(model);
93
+ };
70
94
  function parseSlots(parsed, origin) {
71
95
  const problems = [];
72
96
  const entries = parsed?.slots;
@@ -84,7 +108,8 @@ function parseSlots(parsed, origin) {
84
108
  if (rawBucket !== void 0 && !bucket) {
85
109
  problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
86
110
  }
87
- slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {} });
111
+ const notify = coerceNotify(entry.notify, at, model, problems);
112
+ slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify });
88
113
  });
89
114
  if (slots.length > SLOTS_WARN_AT) {
90
115
  problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
@@ -103,14 +128,19 @@ function parseTextSlots(text, origin) {
103
128
  const [model, ...rest] = line.split(/\s+/);
104
129
  let weight;
105
130
  let bucket;
131
+ let notify;
106
132
  for (const token of rest) {
133
+ if (token.startsWith("notify:")) {
134
+ if (notify === void 0) notify = token.slice("notify:".length);
135
+ continue;
136
+ }
107
137
  if (token.startsWith("b:")) {
108
138
  if (bucket === void 0) bucket = token.slice(2);
109
139
  } else if (weight === void 0) {
110
140
  weight = token;
111
141
  }
112
142
  }
113
- entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket } });
143
+ entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify } });
114
144
  }
115
145
  return parseSlots({ slots: entries }, origin);
116
146
  }
@@ -319,7 +349,7 @@ function ensureSlots(s, onWarn) {
319
349
  for (const problem of problems) onWarn(problem);
320
350
  return slots;
321
351
  }
322
- var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}`).join("|")}`;
352
+ var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}:${slotNotify(slot) ? "1" : "0"}`).join("|")}`;
323
353
  function reloadSlots(s, onWarn) {
324
354
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
325
355
  const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
@@ -885,6 +915,7 @@ export {
885
915
  USER_MODELS_TXT,
886
916
  acquire,
887
917
  agentFor,
918
+ announcesReset,
888
919
  backfillChild,
889
920
  bindChild,
890
921
  claimKey,
@@ -909,6 +940,7 @@ export {
909
940
  manualVariant,
910
941
  markProbe,
911
942
  noteActivity,
943
+ notifyDefault,
912
944
  parseSlotText,
913
945
  parseSlots,
914
946
  parseTextSlots,
@@ -7,7 +7,7 @@ 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.txt` — flat `model [weight]` per line, preferred
10
+ 2. `~/.config/lacode/pool.txt` — flat `model [weight] [b:<bucket>] [notify:on|off]` per line, preferred
11
11
  3. `~/.config/lacode/pool.json` (its parent directory is created for you)
12
12
  4. the bundled `presets/free-tier.json`
13
13
  5. a built-in fallback, so routing always works
@@ -33,6 +33,21 @@ A slot may also declare a shared quota bucket with `b:<name>` on the line (or a
33
33
  the sibling would spend the same exhausted window; without one, the model is its
34
34
  own bucket. `pool_status` shows each shared bucket's effective capacity.
35
35
 
36
+ Each slot gets a `notify` flag that controls the desktop "rate-limit reset"
37
+ notice raised when a slot that was cooling for a quota/rate-limit reason becomes
38
+ usable again. The default is **provider-based**: on for `opencode` and
39
+ `opencode-go` slots, off for every other provider. On a `pool.txt` line, set
40
+ `notify:on` to opt a quiet provider's slot into the notice, or `notify:off` to
41
+ silence an `opencode`/`opencode-go` slot; `notify:on`/`notify:off` are accepted
42
+ explicitly. In `pool.json`, set `"notify": true` or `"notify": false`
43
+ (booleans; `on/off/1/0/yes/no` strings are also accepted). If the same model
44
+ appears on several lines, it is announced if **any** of its slots has `notify`
45
+ on — to fully silence a model, set `notify:off` on all of its lines. An
46
+ invalid `notify` value falls back to the provider default and produces a
47
+ warning. The flag gates only the desktop reset notice, not pool routing or
48
+ status, and pool files are hot-reloaded so changes take effect without a
49
+ restart.
50
+
36
51
 
37
52
  ## Which models run which agent
38
53
 
@@ -222,6 +222,7 @@ A limit does not always arrive as an error. Sometimes the provider accepts the r
222
222
  **Story ID(s) for tests:** US-12
223
223
  **Acceptance criteria:**
224
224
  - [ ] Given a question is asked, When the event fires, Then a `question` notification is delivered that includes the question's text.
225
+ - [ ] Given a subagent session asks a question, When the question fires, Then the notification is still delivered, because the parent cannot proceed until the user answers and a blocked child is not background noise.
225
226
  - [ ] Given a session has a parked question, When the idle event that follows fires, Then no duplicate "finished" notification is sent.
226
227
  - [ ] Given a parked question is replied to, rejected, or the session errors, When that event fires, Then the pending entry is cleared and the next real completion notifies again.
227
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.
@@ -232,6 +233,7 @@ A limit does not always arrive as an error. Sometimes the provider accepts the r
232
233
  **Story ID(s) for tests:** US-25
233
234
  **Acceptance criteria:**
234
235
  - [ ] Given a permission is requested, When the `permission.asked` event fires, Then exactly one notification is delivered naming the permission type and the path or pattern being requested.
236
+ - [ ] Given a subagent session asks for permission, When the prompt fires, Then the notification is still delivered, because the parent cannot proceed until the user answers and a suppressed subagent prompt is the silent stall this story removes.
235
237
  - [ ] 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.
236
238
  - [ ] 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.
237
239
  - [ ] Given a session is parked on a permission prompt, When the idle event fires, Then no "finished" notification is sent, because nothing finished.
@@ -272,6 +274,21 @@ The popup belongs to its session: working in one chat closes that chat's popup a
272
274
  - [ ] 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.
273
275
  - [ ] 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.
274
276
 
277
+ ### US-34: A recovered slot announces itself once, and each slot can stay quiet
278
+ **As a** user running a pool whose slots are rate-limited at different times, **I want** a slot that becomes usable again to raise one notice — and to be able to silence that notice for individual slots, **so that** I hear about capacity coming back without being told about slots I do not care about.
279
+
280
+ A cooldown that was a rate limit is the one kind of recovery worth announcing: when its window lapses or a probe finds the model answering, the capacity is genuinely back. An outage, an auth failure or a missing model is not fixed by waiting, and announcing those would be noise. Several opencode processes share one pool, so the notice is claimed per model and spoken once. The notice follows the provider: `opencode` and `opencode-go` slots announce by default, every other provider is quiet, and any slot can override either way with `notify:on`/`notify:off` (or `"notify": true`/`"notify": false`). The notice names a model, so a model declared on several lines is announced if any of its lines allows it.
281
+
282
+ **Story ID(s) for tests:** US-34
283
+ **Acceptance criteria:**
284
+ - [ ] Given a slot was cooling for a quota/rate-limit reason, When its window lapses or a probe finds it answering, Then one desktop notice with the title `LaCode: rate limit reset` names the recovered model.
285
+ - [ ] Given a slot was cooling for an auth failure, a missing model or a short outage, When that cooldown ends, Then no reset notice is raised, because waiting does not fix those and the alert would be noise.
286
+ - [ ] Given the same pool is shared by several opencode processes, When a slot recovers, Then exactly one process speaks, and the others stay silent.
287
+ - [ ] Given a slot's line sets `notify:off` (or its JSON entry sets `"notify": false`), When that slot recovers, Then no reset notice is raised for it and no cross-process claim is consumed, while other enabled slots still announce.
288
+ - [ ] Given a model appears on several lines, When it recovers, Then it is announced if any of its lines has `notify` on, and is silent only when every line opts out.
289
+ - [ ] Given a slot sets no notify, When it recovers, Then the notice is raised for an opencode or opencode-go slot and not raised for any other provider, because those providers stay quiet unless asked.
290
+ - [ ] Given `notify` is changed in a watched pool file, When the file is saved, Then the new setting applies without a restart.
291
+
275
292
  ---
276
293
 
277
294
  ## Epic E — Remote hosts over SSH
@@ -523,6 +540,7 @@ table was filled in.
523
540
  | US-25 | D | `tests/stories/d-notifications.test.mjs` |
524
541
  | US-29 | D | `tests/stories/d-notifications.test.mjs` |
525
542
  | US-30 | D | `tests/stories/d-notifications.test.mjs`, `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
543
+ | US-34 | D | `tests/pool-models-test.mjs`, `tests/pool-recheck-test.mjs` |
526
544
  | US-13 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/notify-test.mjs`, `tests/oc-test.mjs` |
527
545
  | US-14 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
528
546
  | US-18 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "1.3.0-beta.7",
3
+ "version": "1.3.0-beta.8",
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",
@@ -55,7 +55,7 @@
55
55
  "scripts": {
56
56
  "build": "node scripts/build.mjs",
57
57
  "prepack": "npm run build",
58
- "postpublish": "node scripts/sync-hosts.mjs",
58
+ "postpublish": "node scripts/sync-hosts.mjs && ./bin/oc-sync",
59
59
  "test": "node --test tests/",
60
60
  "benchmark": "node tests/benchmark/run.mjs",
61
61
  "score": "node scripts/score-models.mjs"
@@ -24,14 +24,14 @@
24
24
  // * It never writes pool.txt. It prints a SUGGESTED table for you to paste; the
25
25
  // pool stays yours to decide.
26
26
  // * It never probes a paid model. Only cost 0/0 models from the provider's
27
- // catalog are candidates.
27
+ // catalog are candidates (non-chat/media/safety/meta-router models are skipped).
28
28
  //
29
29
  // OUTPUT
30
30
  // a per-provider ranked table, an overall ranking, a suggested pool.txt block,
31
31
  // and (unless --no-write) the raw rows in ~/.config/lacode/model-scores.json so
32
32
  // a later run can compare.
33
33
 
34
- import { execFileSync } from "node:child_process"
34
+ import { execFileSync, spawn } from "node:child_process"
35
35
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
36
36
  import { homedir } from "node:os"
37
37
  import { dirname, join } from "node:path"
@@ -39,6 +39,7 @@ import { dirname, join } from "node:path"
39
39
  const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
40
40
  const OUT = join(homedir(), ".config", "lacode", "model-scores.json")
41
41
  const DEFAULT_PROVIDERS = ["opencode", "opencode-go", "openrouter", "console-openrouter"]
42
+ const SESSION_PROVIDERS = new Set(["opencode", "opencode-go"])
42
43
 
43
44
  // ── scoring weights ─────────────────────────────────────────────────────────
44
45
  // Mechanical work prizes speed and availability over raw capability. Context is
@@ -59,8 +60,8 @@ const value = (flag, fallback) => {
59
60
  return hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : argv[argv.indexOf(hit) + 1]
60
61
  }
61
62
 
62
- if (has("--help") || has("-h")) {
63
- console.log(`Usage: npm run score -- [options]
63
+ if (has("--help") || has("-h")) {
64
+ console.log(`Usage: npm run score -- [options]
64
65
 
65
66
  --providers a,b providers to scan (default: ${DEFAULT_PROVIDERS.join(",")})
66
67
  --max N probe at most N free models per provider (default: 12)
@@ -69,11 +70,14 @@ if (has("--help") || has("-h")) {
69
70
  --no-write do not write ~/.config/lacode/model-scores.json
70
71
  -h, --help this message
71
72
 
72
- Only cost 0/0 models are candidates. Credentials come from
73
- ~/.local/share/opencode/auth.json; a provider with no entry falls back to the
74
- "opencode" key (which is how the console-openrouter connection authenticates).`)
75
- process.exit(0)
76
- }
73
+ Only cost 0/0 concrete text chat/completion models are candidates
74
+ (non-chat/media generators, safety classifiers, and OpenRouter meta-routers are skipped).
75
+ Credentials come from ~/.local/share/opencode/auth.json; a provider with no
76
+ entry falls back to the "opencode" key (which is how the console-openrouter
77
+ connection authenticates).
78
+ opencode/opencode-go models are probed through a temporary local opencode serve --pure session, while the other providers are probed directly.`)
79
+ process.exit(0)
80
+ }
77
81
 
78
82
  const providers = String(value("--providers", DEFAULT_PROVIDERS.join(","))).split(",").map((s) => s.trim()).filter(Boolean)
79
83
  const maxPerProvider = Math.max(1, Number(value("--max", "12")) || 12)
@@ -119,6 +123,19 @@ function parseVerbose(text) {
119
123
  return rows
120
124
  }
121
125
 
126
+ /** True for concrete text-in/text-out chat models, not media generators, classifiers, or OpenRouter meta-routers. */
127
+ function isChatModel(model) {
128
+ const input = model?.capabilities?.input
129
+ const output = model?.capabilities?.output
130
+ if (input?.text === false) return false
131
+ if (output?.text === false) return false
132
+ if (output?.audio || output?.image || output?.video || output?.pdf) return false
133
+ const apiId = String(model?.api?.id ?? "").toLowerCase()
134
+ if (apiId.startsWith("openrouter/")) return false
135
+ if (/content[-_]safety|moderation|classifier/.test(apiId)) return false
136
+ return true
137
+ }
138
+
122
139
  function freeModels(provider) {
123
140
  let text
124
141
  try {
@@ -135,6 +152,7 @@ function freeModels(provider) {
135
152
  .filter(({ model }) => model?.api?.url && model?.api?.id)
136
153
  .filter(({ model }) => (model.cost?.input ?? 1) === 0 && (model.cost?.output ?? 1) === 0)
137
154
  .filter(({ model }) => !model.status || model.status === "active")
155
+ .filter(({ model }) => isChatModel(model))
138
156
  .map(({ id, model }) => ({
139
157
  model: id,
140
158
  url: model.api.url,
@@ -166,11 +184,104 @@ const sessionHeader = (provider) =>
166
184
  // ── probing ─────────────────────────────────────────────────────────────────
167
185
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
168
186
 
187
+ function failureText(error) {
188
+ if (!error) return "error"
189
+ if (typeof error === "string") return error
190
+ if (error?.data?.message) return error.data.message
191
+ if (error?.message) return error.message
192
+ if (error?.name) return error.name
193
+ try {
194
+ return JSON.stringify(error).slice(0, 240)
195
+ } catch {
196
+ return "error"
197
+ }
198
+ }
199
+
200
+ let sessionServerPromise = null
201
+ let sessionServerProc = null
202
+
203
+ function startSessionServer() {
204
+ if (sessionServerPromise) return sessionServerPromise
205
+ sessionServerPromise = new Promise((resolve, reject) => {
206
+ let resolved = false
207
+ let output = ""
208
+ let proc
209
+ try {
210
+ proc = spawn("opencode", ["serve", "--hostname=127.0.0.1", "--port=0", "--pure"], {
211
+ stdio: ["ignore", "pipe", "pipe"],
212
+ })
213
+ } catch (error) {
214
+ sessionServerPromise = null
215
+ sessionServerProc = null
216
+ reject(error)
217
+ return
218
+ }
219
+ sessionServerProc = proc
220
+
221
+ const timer = setTimeout(() => {
222
+ if (resolved) return
223
+ resolved = true
224
+ try {
225
+ proc.kill()
226
+ } catch {}
227
+ sessionServerPromise = null
228
+ sessionServerProc = null
229
+ reject(new Error("opencode serve timeout"))
230
+ }, 15000)
231
+
232
+ const onData = (chunk) => {
233
+ if (resolved) return
234
+ output += chunk.toString()
235
+ const match = output.match(/opencode server listening on (https?:\/\/\S+)/)
236
+ if (match) {
237
+ resolved = true
238
+ clearTimeout(timer)
239
+ const url = match[1].replace(/\/+$/, "")
240
+ resolve({ url, proc })
241
+ }
242
+ }
243
+ proc.stdout.on("data", onData)
244
+ proc.stderr.on("data", onData)
245
+
246
+ proc.on("error", (error) => {
247
+ if (resolved) return
248
+ resolved = true
249
+ clearTimeout(timer)
250
+ sessionServerPromise = null
251
+ sessionServerProc = null
252
+ reject(error)
253
+ })
254
+
255
+ proc.on("exit", () => {
256
+ if (sessionServerProc === proc) {
257
+ sessionServerProc = null
258
+ sessionServerPromise = null
259
+ }
260
+ if (resolved) return
261
+ resolved = true
262
+ clearTimeout(timer)
263
+ reject(new Error("opencode serve exited before ready"))
264
+ })
265
+ })
266
+ return sessionServerPromise
267
+ }
268
+
269
+ function stopSessionServer() {
270
+ if (sessionServerProc) {
271
+ try {
272
+ sessionServerProc.kill()
273
+ } catch {}
274
+ sessionServerProc = null
275
+ }
276
+ sessionServerPromise = null
277
+ }
278
+ process.on("exit", stopSessionServer)
279
+
169
280
  /**
170
281
  * One real request. Returns {ok, ms, reason}. A failure is named, never rounded
171
282
  * into a success, and `ms` is only reported for a request that actually ran.
172
283
  */
173
- async function probe(candidate, provider) {
284
+ async function probeDirect(candidate, provider) {
174
285
  const key = keyFor(provider)
175
286
  if (!key) return { ok: false, ms: null, reason: "no credential in auth.json" }
176
287
 
@@ -226,6 +337,84 @@ async function probe(candidate, provider) {
226
337
  }
227
338
  }
228
339
 
340
+ async function probeViaSession(candidate, provider) {
341
+ const started = performance.now()
342
+ let promptStarted = null
343
+ const directory = encodeURIComponent(process.cwd())
344
+ let id = null
345
+ let server = null
346
+ try {
347
+ server = await startSessionServer()
348
+ const createRes = await fetch(`${server.url}/session?directory=${directory}`, {
349
+ method: "POST",
350
+ headers: { "Content-Type": "application/json" },
351
+ body: JSON.stringify({ title: `score probe ${candidate.model}` }),
352
+ signal: AbortSignal.timeout(timeoutMs),
353
+ })
354
+ const createText = await createRes.text()
355
+ let createdJson = null
356
+ try {
357
+ createdJson = JSON.parse(createText)
358
+ } catch {}
359
+ if (!createRes.ok) {
360
+ const detail = createdJson?.error?.message ?? createdJson?.message ?? createText.slice(0, 160)
361
+ const ms = Math.round(performance.now() - started)
362
+ return { ok: false, ms, reason: `session create http ${createRes.status}: ${detail}` }
363
+ }
364
+ id = createdJson?.id ?? createdJson?.data?.id ?? null
365
+ if (!id) {
366
+ const ms = Math.round(performance.now() - started)
367
+ return { ok: false, ms, reason: `no session id: ${createText.slice(0, 160)}` }
368
+ }
369
+ promptStarted = performance.now()
370
+ const msgRes = await fetch(`${server.url}/session/${id}/message?directory=${directory}`, {
371
+ method: "POST",
372
+ headers: { "Content-Type": "application/json" },
373
+ body: JSON.stringify({
374
+ model: { providerID: provider, modelID: candidate.apiId },
375
+ system: "You are a connection health check. Reply with the single word: ok",
376
+ parts: [{ type: "text", text: "ok" }],
377
+ }),
378
+ signal: AbortSignal.timeout(timeoutMs),
379
+ })
380
+ const msgText = await msgRes.text()
381
+ let parsed = null
382
+ try {
383
+ parsed = JSON.parse(msgText)
384
+ } catch {}
385
+ const ms = Math.round(performance.now() - (promptStarted ?? started))
386
+ if (!msgRes.ok) {
387
+ const detail = parsed?.error?.message ?? parsed?.message ?? msgText.slice(0, 160)
388
+ return { ok: false, ms, reason: `http ${msgRes.status}: ${detail}` }
389
+ }
390
+ const failure = parsed?.error ?? parsed?.info?.error
391
+ if (failure) {
392
+ return { ok: false, ms, reason: failureText(failure).slice(0, 240) }
393
+ }
394
+ if (!parsed?.info) {
395
+ return { ok: false, ms, reason: "no response" }
396
+ }
397
+ return { ok: true, ms, reason: "answered" }
398
+ } catch (error) {
399
+ const ms = Math.round(performance.now() - (promptStarted ?? started))
400
+ if (error?.name === "TimeoutError") {
401
+ return { ok: false, ms, reason: `timeout after ${timeoutMs}ms` }
402
+ }
403
+ return { ok: false, ms, reason: failureText(error).slice(0, 240) }
404
+ } finally {
405
+ if (id && server) {
406
+ try {
407
+ await fetch(`${server.url}/session/${id}?directory=${directory}`, { method: "DELETE" })
408
+ } catch {}
409
+ }
410
+ }
411
+ }
412
+
413
+ async function probe(candidate, provider) {
414
+ if (SESSION_PROVIDERS.has(provider)) return probeViaSession(candidate, provider)
415
+ return probeDirect(candidate, provider)
416
+ }
417
+
229
418
  // ── scoring ─────────────────────────────────────────────────────────────────
230
419
  const clamp01 = (n) => Math.max(0, Math.min(1, n))
231
420
  const logScore = (n, max) => (n && n > 0 ? clamp01(Math.log2(1 + n) / Math.log2(1 + max)) : 0)
@@ -274,6 +463,7 @@ async function main() {
274
463
  }
275
464
  results.push({ provider: entry.provider, error: null, rows })
276
465
  }
466
+ stopSessionServer()
277
467
 
278
468
  // Per-provider ranked tables.
279
469
  for (const entry of results) {