@lawaty/lacode 1.3.0-beta.8 → 1.3.1

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
@@ -9,6 +9,24 @@ commit as the changelog entry it describes.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.3.1] — reprocess-timeline rebuilds the caller's timeline, and the pool refreshes daily
13
+
14
+ ### Added
15
+
16
+ - Pool: `scripts/refresh-pool.mjs` rebuilds `~/.config/lacode/pool.txt` from the score output — opencode-go free models first, preserved unscored providers in the middle, the best opencode model last — with an atomic, fail-closed write. `--install-timer` installs a daily systemd user timer; `--from-scores` reuses an existing `model-scores.json`.
17
+
18
+ ### Fixed
19
+
20
+ - Timeline: `/reprocess-timeline` now runs inline (`subtask: false`) instead of in a child session, so `timeline_reprocess` rebuilds the caller's timeline rather than the child's.
21
+
22
+ ## [1.3.0-beta.9] — a timeline reply without bullets still lands
23
+
24
+ ### Fixed
25
+
26
+ - Timeline: a summarizer reply that arrives only as reasoning content is now read, so a rebuild no longer produces an empty timeline.
27
+ - Timeline: a non-bulleted summary is salvaged into entries instead of aborting the rebuild with `no bullet lines`.
28
+ - Timeline: summarizer failures now report the underlying message instead of `[object Object]`.
29
+
12
30
  ## [1.3.0-beta.8] — a blocked child is never silent
13
31
 
14
32
  ### Added
@@ -1097,7 +1115,9 @@ experimental; `latest` remains 0.4.2.
1097
1115
 
1098
1116
  - First published release: pooled subagents over a shared set of models.
1099
1117
 
1100
- [Unreleased]: https://github.com/lawaty/lacode/compare/v1.3.0-beta.8...HEAD
1118
+ [Unreleased]: https://github.com/lawaty/lacode/compare/v1.3.1...HEAD
1119
+ [1.3.1]: https://github.com/lawaty/lacode/releases/tag/v1.3.1
1120
+ [1.3.0-beta.9]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.9
1101
1121
  [1.3.0-beta.8]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.8
1102
1122
  [1.3.0-beta.0]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.0
1103
1123
  [1.2.0]: https://github.com/lawaty/lacode/compare/v1.1.0...v1.2.0
package/dist/commands.js CHANGED
@@ -225,9 +225,13 @@ $ARGUMENTS
225
225
 
226
226
  Report the tool's output as it came back, then add one short line naming what changed: re-admitted, still limited, or benched because the provider does not serve the model. If a model was benched as \`model-missing\`, say so and name the replacement in \`~/.config/lacode/pool.txt\` \u2014 but change nothing yourself.`
227
227
  },
228
+ // `agent: "implement"` is a subagent, so without `subtask: false` this command
229
+ // would run in a child session and `timeline_reprocess` would rebuild the
230
+ // child's timeline instead of the caller's.
228
231
  "reprocess-timeline": {
229
232
  description: "Rebuild this session's timeline from its full transcript",
230
233
  agent: "implement",
234
+ subtask: false,
231
235
  template: "Call the `timeline_reprocess` tool exactly once with no arguments. Then reply with only its output."
232
236
  },
233
237
  "image-in-cb": {
package/dist/index.js CHANGED
@@ -2085,9 +2085,13 @@ $ARGUMENTS
2085
2085
 
2086
2086
  Report the tool's output as it came back, then add one short line naming what changed: re-admitted, still limited, or benched because the provider does not serve the model. If a model was benched as \`model-missing\`, say so and name the replacement in \`~/.config/lacode/pool.txt\` \u2014 but change nothing yourself.`
2087
2087
  },
2088
+ // `agent: "implement"` is a subagent, so without `subtask: false` this command
2089
+ // would run in a child session and `timeline_reprocess` would rebuild the
2090
+ // child's timeline instead of the caller's.
2088
2091
  "reprocess-timeline": {
2089
2092
  description: "Rebuild this session's timeline from its full transcript",
2090
2093
  agent: "implement",
2094
+ subtask: false,
2091
2095
  template: "Call the `timeline_reprocess` tool exactly once with no arguments. Then reply with only its output."
2092
2096
  },
2093
2097
  "image-in-cb": {
@@ -6987,6 +6991,29 @@ function parseBullets(text, max = 6) {
6987
6991
  }
6988
6992
  return result;
6989
6993
  }
6994
+ function salvageBullets(text, max = 6) {
6995
+ const result = [];
6996
+ const seen = /* @__PURE__ */ new Set();
6997
+ for (const raw of text.split("\n")) {
6998
+ let line = raw.trim();
6999
+ if (!line) continue;
7000
+ if (line.startsWith("```")) continue;
7001
+ if (line.startsWith("#")) continue;
7002
+ if (line.length > 320) continue;
7003
+ line = line.replace(/^([-*+•]|\d+[.)])\s*/, "").trim();
7004
+ if (!line) continue;
7005
+ line = line.replace(/`/g, "").replace(/\*\*/g, "").replace(/__/g, "").trim();
7006
+ const cleaned = stripAnsiAndControls(line).trim();
7007
+ if (!cleaned || cleaned.length > 320) continue;
7008
+ if (cleaned.endsWith(":")) continue;
7009
+ const key = cleaned.toLowerCase();
7010
+ if (seen.has(key)) continue;
7011
+ seen.add(key);
7012
+ result.push(cleaned);
7013
+ if (result.length >= max) break;
7014
+ }
7015
+ return result;
7016
+ }
6990
7017
  function isInternalTitle(title) {
6991
7018
  const t = (title ?? "").trim();
6992
7019
  return t.startsWith("context (auto") || t.startsWith("timeline (auto") || t.startsWith("pool probe");
@@ -7047,6 +7074,20 @@ This:
7047
7074
  - Providers: replaced the retired Gemini 2.5 Flash with Gemini 3.5 Flash.
7048
7075
 
7049
7076
  Your entire response must consist only of lines beginning with "- ".`;
7077
+ function errorText(value) {
7078
+ if (value instanceof Error) return value.message;
7079
+ if (typeof value === "string") return value;
7080
+ if (value && typeof value === "object") {
7081
+ const message = value.message;
7082
+ if (typeof message === "string" && message) return message;
7083
+ try {
7084
+ return JSON.stringify(value);
7085
+ } catch {
7086
+ return String(value);
7087
+ }
7088
+ }
7089
+ return String(value);
7090
+ }
7050
7091
  function timelineHooks(input, options) {
7051
7092
  const { client, directory } = input;
7052
7093
  const opts = options ?? {};
@@ -7210,21 +7251,31 @@ function timelineHooks(input, options) {
7210
7251
  }
7211
7252
  const failure = result?.error ?? result?.data?.info?.error;
7212
7253
  if (timedOut || failure !== void 0) {
7213
- return { ok: false, bullets: [], error: timedOut ? "timed out" : String(failure) };
7214
- } else {
7215
- const text = (result?.data?.parts ?? []).map((part) => part.type === "text" ? part.text ?? "" : "").join("\n");
7216
- const bullets = parseBullets(text, maxEntries);
7217
- if (bullets.length > 0) {
7218
- return { ok: true, bullets };
7219
- } else if (text.trim() === "") {
7220
- return { ok: true, bullets: [] };
7221
- } else {
7222
- if (process.env.LACODE_TIMELINE_DEBUG === "1") {
7223
- log("debug", "timeline: no bullet lines", { sessionID: id, text: text.slice(0, 300) });
7224
- }
7225
- return { ok: false, bullets: [], error: "no bullet lines" };
7226
- }
7254
+ return { ok: false, bullets: [], error: timedOut ? "timed out" : errorText(failure) };
7255
+ }
7256
+ if (!result?.data) {
7257
+ return { ok: false, bullets: [], error: "no response" };
7258
+ }
7259
+ const parts = result.data.parts ?? [];
7260
+ const textParts = parts.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n").trim();
7261
+ const reasoningParts = parts.filter((part) => part.type === "reasoning").map((part) => part.text ?? "").join("\n").trim();
7262
+ const text = textParts || reasoningParts;
7263
+ const bullets = parseBullets(text, maxEntries);
7264
+ if (bullets.length > 0) {
7265
+ return { ok: true, bullets };
7266
+ }
7267
+ if (text.trim() === "") {
7268
+ return { ok: true, bullets: [] };
7269
+ }
7270
+ const salvaged = salvageBullets(text, maxEntries);
7271
+ if (salvaged.length > 0) {
7272
+ log("warn", "timeline: salvaged non-bulleted output", { sessionID: id, text: text.slice(0, 300) });
7273
+ return { ok: true, bullets: salvaged };
7274
+ }
7275
+ if (process.env.LACODE_TIMELINE_DEBUG === "1") {
7276
+ log("debug", "timeline: no bullet lines", { sessionID: id, text: text.slice(0, 300) });
7227
7277
  }
7278
+ return { ok: false, bullets: [], error: "no bullet lines" };
7228
7279
  } finally {
7229
7280
  if (abort && abortListener) abort.removeEventListener("abort", abortListener);
7230
7281
  if (claimed && claimKey2) release(poolState, claimKey2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "1.3.0-beta.8",
3
+ "version": "1.3.1",
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",
@@ -50,7 +50,8 @@
50
50
  "CHANGELOG.md",
51
51
  "README.md",
52
52
  "docs",
53
- "scripts/score-models.mjs"
53
+ "scripts/score-models.mjs",
54
+ "scripts/refresh-pool.mjs"
54
55
  ],
55
56
  "scripts": {
56
57
  "build": "node scripts/build.mjs",
@@ -58,7 +59,8 @@
58
59
  "postpublish": "node scripts/sync-hosts.mjs && ./bin/oc-sync",
59
60
  "test": "node --test tests/",
60
61
  "benchmark": "node tests/benchmark/run.mjs",
61
- "score": "node scripts/score-models.mjs"
62
+ "score": "node scripts/score-models.mjs",
63
+ "refresh-pool": "node scripts/refresh-pool.mjs"
62
64
  },
63
65
  "devDependencies": {
64
66
  "esbuild": "^0.28.2"
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env node
2
+ // Daily-refreshable pool builder.
3
+ //
4
+ // Reads the scorer's output (~/.config/lacode/model-scores.json) and proposes a
5
+ // rewritten ~/.config/lacode/pool.txt. It never partially writes and never
6
+ // writes on a fail-closed condition.
7
+ //
8
+ // Layout (exact, per spec):
9
+ // 1. every free opencode-go model with ok:true and a numeric score, one slot
10
+ // each, sorted by score descending, weight 4
11
+ // 2. every slot from the current pool whose provider is NOT present in the
12
+ // score file, preserved exactly (raw line text, weight/bucket/notify, order)
13
+ // 3. exactly one free opencode model — the best ok:true numeric score —
14
+ // weight 2, with b:opencode
15
+ //
16
+ // Total = #opencode-go free + #preserved unscored + 1.
17
+ //
18
+ // This file ships as source and must run with plain `node` (ESM, no TS).
19
+
20
+ import { mkdirSync, renameSync, writeFileSync, readFileSync, existsSync } from "node:fs"
21
+ import { execFileSync } from "node:child_process"
22
+ import { homedir } from "node:os"
23
+ import { dirname, join, resolve } from "node:path"
24
+ import { fileURLToPath } from "node:url"
25
+
26
+ const HOME = homedir()
27
+ export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
28
+ export const DEFAULT_POOL_PATH = join(HOME, ".config", "lacode", "pool.txt")
29
+ export const SYSTEMD_USER_DIR = (home = HOME) => join(home, ".config", "systemd", "user")
30
+ export const REFRESH_SERVICE_NAME = "lacode-refresh-pool.service"
31
+ export const REFRESH_TIMER_NAME = "lacode-refresh-pool.timer"
32
+
33
+ const isSlotLine = (line) => {
34
+ const stripped = String(line).split("#")[0].trim()
35
+ return stripped.length > 0 && stripped.includes("/")
36
+ }
37
+
38
+ const providerOf = (modelOrLine) => {
39
+ const stripped = String(modelOrLine).split("#")[0].trim()
40
+ const token = stripped.split(/\s+/)[0] || ""
41
+ const slash = token.indexOf("/")
42
+ return slash < 0 ? token : token.slice(0, slash)
43
+ }
44
+
45
+ const isFree = (modelId) => {
46
+ const id = String(modelId || "")
47
+ return id.endsWith(":free") || id.endsWith("-free")
48
+ }
49
+
50
+ const numericScore = (row) => typeof row?.score === "number" && Number.isFinite(row.score)
51
+
52
+ const okFreeRows = (providerEntry) => {
53
+ const rows = Array.isArray(providerEntry?.rows) ? providerEntry.rows : []
54
+ return rows.filter((row) => row?.ok === true && numericScore(row) && isFree(row.model))
55
+ }
56
+
57
+ function splitHeaderAndSlots(text) {
58
+ const lines = String(text ?? "").split(/\r?\n/)
59
+ // Drop a single trailing empty element produced by a final newline so we can
60
+ // reason about the file's logical lines; we re-add a final newline on output.
61
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
62
+ let first = -1
63
+ for (let i = 0; i < lines.length; i++) {
64
+ if (isSlotLine(lines[i])) { first = i; break }
65
+ }
66
+ if (first === -1) return { header: lines, slots: [] }
67
+ return { header: lines.slice(0, first), slots: lines.slice(first) }
68
+ }
69
+
70
+ function refreshComment(now, scoresGeneratedAt) {
71
+ const when = now instanceof Date ? now.toISOString() : String(now)
72
+ const gen = scoresGeneratedAt ? ` (scores generated ${scoresGeneratedAt})` : ""
73
+ return `# Refreshed ${when} from model-scores.json${gen}: opencode-go free models first, best opencode last.`
74
+ }
75
+
76
+ function withRefreshComment(headerLines, comment) {
77
+ const kept = headerLines.filter((line) => !/^#\s*Refreshed\b/.test(String(line)))
78
+ return [...kept, comment]
79
+ }
80
+
81
+ function formatGeneratedLine(model, weight, bucket) {
82
+ const parts = [String(model), String(weight)]
83
+ if (bucket) parts.push(`b:${bucket}`)
84
+ return parts.join(" ")
85
+ }
86
+
87
+ /**
88
+ * Pure builder. Throws on every fail-closed condition; never touches the filesystem.
89
+ *
90
+ * @param {{ scores: any, currentPoolText: string, now?: Date|string }} input
91
+ * @returns {{ text: string, slots: string[], warnings: string[], chosen: { opencodeGo: string[], opencode: string, preserved: string[] } }}
92
+ */
93
+ export function buildPool(input) {
94
+ const warnings = []
95
+ const scores = input?.scores
96
+ if (!scores || typeof scores !== "object" || !Array.isArray(scores.providers)) {
97
+ throw new Error("score file is missing or invalid (expected { providers: [...] })")
98
+ }
99
+
100
+ const providerEntries = scores.providers
101
+ const scoredProviders = new Set(
102
+ providerEntries.map((p) => String(p?.provider ?? "").trim()).filter(Boolean),
103
+ )
104
+
105
+ const goEntry = providerEntries.find((p) => p?.provider === "opencode-go")
106
+ const goRows = okFreeRows(goEntry).sort((a, b) => b.score - a.score)
107
+ if (goRows.length === 0) {
108
+ throw new Error("no free opencode-go model with ok:true and a numeric score is available; refusing to write pool.txt")
109
+ }
110
+
111
+ const opEntry = providerEntries.find((p) => p?.provider === "opencode")
112
+ const opRows = okFreeRows(opEntry).sort((a, b) => b.score - a.score)
113
+ if (opRows.length === 0) {
114
+ throw new Error("no free opencode model with ok:true and a numeric score is available; refusing to write pool.txt")
115
+ }
116
+ const bestOp = opRows[0].model
117
+
118
+ const { header, slots: currentSlots } = splitHeaderAndSlots(input?.currentPoolText ?? "")
119
+ const preserved = currentSlots.filter((line) => {
120
+ if (!isSlotLine(line)) return false
121
+ return !scoredProviders.has(providerOf(line))
122
+ })
123
+
124
+ const block1 = goRows.map((row) => formatGeneratedLine(row.model, 4))
125
+ const block3 = [formatGeneratedLine(bestOp, 2, "opencode")]
126
+ const outSlots = [...block1, ...preserved, ...block3]
127
+
128
+ const comment = refreshComment(input?.now ?? new Date(), scores.generatedAt)
129
+ const outHeader = withRefreshComment(header, comment)
130
+
131
+ const text = [...outHeader, "", ...outSlots, ""].join("\n")
132
+ return {
133
+ text,
134
+ slots: outSlots,
135
+ warnings,
136
+ chosen: {
137
+ opencodeGo: goRows.map((r) => r.model),
138
+ opencode: bestOp,
139
+ preserved: preserved.map((l) => String(l).split("#")[0].trim().split(/\s+/)[0]),
140
+ },
141
+ }
142
+ }
143
+
144
+ function readJsonFile(path) {
145
+ if (!existsSync(path)) throw new Error(`file not found: ${path}`)
146
+ const raw = readFileSync(path, "utf8")
147
+ try {
148
+ return JSON.parse(raw)
149
+ } catch (error) {
150
+ throw new Error(`invalid JSON in ${path}: ${String(error?.message ?? error)}`)
151
+ }
152
+ }
153
+
154
+ function readTextFile(path) {
155
+ if (!existsSync(path)) return ""
156
+ return readFileSync(path, "utf8")
157
+ }
158
+
159
+ function atomicWriteSimple(path, text) {
160
+ const dir = dirname(path)
161
+ const tmp = join(dir, `.pool.txt.tmp-${process.pid}-${Date.now()}`)
162
+ writeFileSync(tmp, text, "utf8")
163
+ renameSync(tmp, path)
164
+ }
165
+
166
+ function runScorer(max) {
167
+ const here = dirname(fileURLToPath(import.meta.url))
168
+ const scorer = resolve(here, "score-models.mjs")
169
+ if (!existsSync(scorer)) throw new Error(`scorer not found: ${scorer}`)
170
+ const args = [scorer, "--max", String(max)]
171
+ execFileSync(process.execPath, args, { stdio: "inherit" })
172
+ }
173
+
174
+ export function renderTimerUnits(input = {}) {
175
+ const nodePath = input.nodePath || process.execPath
176
+ const scriptPath = input.scriptPath || fileURLToPath(import.meta.url)
177
+ const home = input.home || HOME
178
+ const servicePath = join(SYSTEMD_USER_DIR(home), REFRESH_SERVICE_NAME)
179
+ const timerPath = join(SYSTEMD_USER_DIR(home), REFRESH_TIMER_NAME)
180
+ const service = `[Unit]
181
+ Description=Refresh the LaCode model pool from free-model scores
182
+ After=network-online.target
183
+ Wants=network-online.target
184
+
185
+ [Service]
186
+ Type=oneshot
187
+ ExecStart=${nodePath} ${scriptPath}
188
+ Environment=PATH=${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin
189
+ Nice=10
190
+ `
191
+ const timer = `[Unit]
192
+ Description=Daily LaCode model pool refresh
193
+
194
+ [Timer]
195
+ OnCalendar=daily
196
+ RandomizedDelaySec=2h
197
+ Persistent=true
198
+ Unit=${REFRESH_SERVICE_NAME}
199
+
200
+ [Install]
201
+ WantedBy=timers.target
202
+ `
203
+ return { service, timer, servicePath, timerPath }
204
+ }
205
+
206
+ function installTimerUnits() {
207
+ const units = renderTimerUnits({
208
+ nodePath: process.execPath,
209
+ scriptPath: fileURLToPath(import.meta.url),
210
+ home: HOME,
211
+ })
212
+ mkdirSync(dirname(units.servicePath), { recursive: true })
213
+ writeFileSync(units.servicePath, units.service, "utf8")
214
+ writeFileSync(units.timerPath, units.timer, "utf8")
215
+ try {
216
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" })
217
+ execFileSync("systemctl", ["--user", "enable", "--now", REFRESH_TIMER_NAME], { stdio: "inherit" })
218
+ } catch (error) {
219
+ console.error(`failed to install systemd user timer: ${String(error?.message ?? error)}`)
220
+ console.error(`unit files written:`)
221
+ console.error(` ${units.servicePath}`)
222
+ console.error(` ${units.timerPath}`)
223
+ console.error(`manual commands:`)
224
+ console.error(` systemctl --user daemon-reload`)
225
+ console.error(` systemctl --user enable --now ${REFRESH_TIMER_NAME}`)
226
+ process.exitCode = 1
227
+ return false
228
+ }
229
+ console.log(`installed ${units.servicePath}`)
230
+ console.log(`installed ${units.timerPath}`)
231
+ console.log(`enabled ${REFRESH_TIMER_NAME}`)
232
+ return true
233
+ }
234
+
235
+ function usage() {
236
+ console.log(`Usage: node scripts/refresh-pool.mjs [options]
237
+
238
+ --from-scores skip scoring, use the existing ~/.config/lacode/model-scores.json
239
+ --install-timer install and enable a user systemd oneshot service + daily timer
240
+ --print print the proposed pool.txt and make no changes (alias: --dry-run)
241
+ --dry-run same as --print
242
+ --max N models per provider passed to the scorer (default: 20)
243
+ --scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
244
+ --pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
245
+ -h, --help this message`)
246
+ }
247
+
248
+ async function main() {
249
+ const argv = process.argv.slice(2)
250
+ if (argv.includes("--help") || argv.includes("-h")) { usage(); return }
251
+ if (argv.includes("--install-timer")) {
252
+ installTimerUnits()
253
+ return
254
+ }
255
+ const fromScores = argv.includes("--from-scores")
256
+ const printOnly = argv.includes("--print") || argv.includes("--dry-run")
257
+ const value = (flag, fallback) => {
258
+ const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`))
259
+ if (!hit) return fallback
260
+ return hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : argv[argv.indexOf(hit) + 1]
261
+ }
262
+ const max = Math.max(1, Number(value("--max", "20")) || 20)
263
+ const scoresPath = value("--scores", DEFAULT_SCORES_PATH)
264
+ const poolPath = value("--pool", DEFAULT_POOL_PATH)
265
+
266
+ if (!fromScores) {
267
+ try {
268
+ runScorer(max)
269
+ } catch (error) {
270
+ console.error(`scorer failed; pool.txt not written: ${String(error?.message ?? error)}`)
271
+ process.exitCode = 1
272
+ return
273
+ }
274
+ }
275
+
276
+ let scores
277
+ try {
278
+ scores = readJsonFile(scoresPath)
279
+ } catch (error) {
280
+ console.error(String(error?.message ?? error))
281
+ process.exitCode = 1
282
+ return
283
+ }
284
+
285
+ const currentPoolText = readTextFile(poolPath)
286
+ let built
287
+ try {
288
+ built = buildPool({ scores, currentPoolText, now: new Date() })
289
+ } catch (error) {
290
+ console.error(`refresh refused (fail closed): ${String(error?.message ?? error)}`)
291
+ process.exitCode = 1
292
+ return
293
+ }
294
+
295
+ const oldSlotCount = splitHeaderAndSlots(currentPoolText).slots.filter(isSlotLine).length
296
+ const newSlotCount = built.slots.filter(isSlotLine).length
297
+ const summary = [
298
+ `opencode-go [${built.chosen.opencodeGo.join(", ")}]`,
299
+ `preserved-unscored [${built.chosen.preserved.join(", ")}]`,
300
+ `opencode-last [${built.chosen.opencode}]`,
301
+ ].join(" | ")
302
+
303
+ if (printOnly) {
304
+ process.stdout.write(built.text)
305
+ console.error(`--print: proposed ${oldSlotCount} -> ${newSlotCount} slots; no changes written. ${summary}`)
306
+ return
307
+ }
308
+
309
+ if (existsSync(poolPath)) {
310
+ try {
311
+ const existing = readFileSync(poolPath, "utf8")
312
+ if (existing === built.text) {
313
+ console.log(`unchanged: ${poolPath} (${newSlotCount} slots). ${summary}`)
314
+ return
315
+ }
316
+ } catch {}
317
+ }
318
+
319
+ try {
320
+ atomicWriteSimple(poolPath, built.text)
321
+ } catch (error) {
322
+ console.error(`failed to write ${poolPath}: ${String(error?.message ?? error)}`)
323
+ process.exitCode = 1
324
+ return
325
+ }
326
+ console.log(`wrote ${poolPath}: ${oldSlotCount} -> ${newSlotCount} slots. ${summary}`)
327
+ }
328
+
329
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
330
+ if (isMain) {
331
+ main().catch((error) => {
332
+ console.error(String(error?.stack ?? error?.message ?? error))
333
+ process.exitCode = 1
334
+ })
335
+ }