@desplega.ai/agent-swarm 1.97.0 → 1.98.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/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.97.0",
5
+ "version": "1.98.1",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.97.0",
3
+ "version": "1.98.1",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -95,7 +95,7 @@
95
95
  "devDependencies": {
96
96
  "@biomejs/biome": "^2.3.10",
97
97
  "@faker-js/faker": "^10.4.0",
98
- "@opencode-ai/plugin": "1.14.30",
98
+ "@opencode-ai/plugin": "1.17.4",
99
99
  "@types/bun": "latest"
100
100
  },
101
101
  "peerDependencies": {
@@ -118,7 +118,7 @@
118
118
  "@linear/sdk": "^77.0.0",
119
119
  "@modelcontextprotocol/sdk": "^1.25.1",
120
120
  "@openai/codex-sdk": "^0.139.0",
121
- "@opencode-ai/sdk": "^1.16.2",
121
+ "@opencode-ai/sdk": "^1.17.4",
122
122
  "@openfort/openfort-node": "^0.9.1",
123
123
  "@opentelemetry/api": "^1.9.1",
124
124
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
@@ -24,6 +24,11 @@ export const MODELSDEV_CACHE_PATH = path.join("src", "be", "modelsdev-cache.json
24
24
  /**
25
25
  * Resolve the vendored models.dev cache from source checkouts and compiled
26
26
  * Docker images. The API image copies the snapshot to `/app/src/be/...`.
27
+ *
28
+ * This file is now fallback-only for pricing freshness: boot seeding uses it
29
+ * when the DB is empty or models.dev is unavailable, while
30
+ * `src/be/pricing-refresh.ts` owns live price updates. The UI model picker
31
+ * still imports the same snapshot for names, labels, and context windows.
27
32
  */
28
33
  export function loadModelsDevCache(): ModelsDevCache | null {
29
34
  const explicitPath = process.env.MODELSDEV_CACHE_PATH;
@@ -0,0 +1,189 @@
1
+ import { scrubSecrets } from "../utils/secret-scrubber";
2
+ import {
3
+ createLogEntry,
4
+ getActivePricingRow,
5
+ getDb,
6
+ type InsertPricingRowInput,
7
+ insertPricingRow,
8
+ } from "./db";
9
+ import type { ModelsDevCache } from "./modelsdev-cache";
10
+ import { buildModelsDevSeedRows, type PricingSeedRow } from "./seed-pricing";
11
+
12
+ const MODELSDEV_API_URL = "https://models.dev/api.json";
13
+ export const PRICING_REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000;
14
+
15
+ let lastETag: string | null = null;
16
+ let refreshLoopStarted = false;
17
+
18
+ interface RefreshPricingOptions {
19
+ fetchImpl?: typeof fetch;
20
+ now?: number;
21
+ }
22
+
23
+ export interface PricingRefreshResult {
24
+ status: "refreshed" | "not_modified";
25
+ candidateRows: number;
26
+ inserted: number;
27
+ unchanged: number;
28
+ pruned: number;
29
+ etag?: string;
30
+ }
31
+
32
+ function logPricingRefresh(message: string): void {
33
+ console.log(scrubSecrets(`[pricing-refresh] ${message}`));
34
+ }
35
+
36
+ function logPricingRefreshError(message: string, err: unknown): void {
37
+ const detail = err instanceof Error ? err.message : String(err);
38
+ console.warn(scrubSecrets(`[pricing-refresh] ${message}: ${detail}`));
39
+ }
40
+
41
+ function insertChangedPricingRows(
42
+ rows: PricingSeedRow[],
43
+ now: number,
44
+ ): {
45
+ inserted: number;
46
+ unchanged: number;
47
+ } {
48
+ let inserted = 0;
49
+ let unchanged = 0;
50
+
51
+ const tx = getDb().transaction((seedRows: PricingSeedRow[]) => {
52
+ for (const row of seedRows) {
53
+ const existing = getActivePricingRow(row.provider, row.model, row.tokenClass, now);
54
+ if (existing?.pricePerMillionUsd === row.pricePerMillionUsd) {
55
+ unchanged += 1;
56
+ continue;
57
+ }
58
+
59
+ const input: InsertPricingRowInput = {
60
+ ...row,
61
+ effectiveFrom: now,
62
+ };
63
+ insertPricingRow(input);
64
+ inserted += 1;
65
+ }
66
+ });
67
+
68
+ tx(rows);
69
+ return { inserted, unchanged };
70
+ }
71
+
72
+ function prunePricingHistory(keepLatest = 2): number {
73
+ const result = getDb()
74
+ .prepare(
75
+ `DELETE FROM pricing
76
+ WHERE rowid IN (
77
+ SELECT rowid
78
+ FROM (
79
+ SELECT
80
+ rowid,
81
+ ROW_NUMBER() OVER (
82
+ PARTITION BY provider, model, token_class
83
+ ORDER BY effective_from DESC
84
+ ) AS rn
85
+ FROM pricing
86
+ )
87
+ WHERE rn > ?
88
+ )`,
89
+ )
90
+ .run(keepLatest);
91
+ return result.changes;
92
+ }
93
+
94
+ function auditPricingRefresh(result: PricingRefreshResult): void {
95
+ try {
96
+ createLogEntry({
97
+ eventType: "pricing.refresh",
98
+ newValue: `${result.status}: inserted=${result.inserted}; unchanged=${result.unchanged}; pruned=${result.pruned}`,
99
+ metadata: {
100
+ status: result.status,
101
+ candidateRows: result.candidateRows,
102
+ inserted: result.inserted,
103
+ unchanged: result.unchanged,
104
+ pruned: result.pruned,
105
+ etag: result.etag,
106
+ },
107
+ });
108
+ } catch (err) {
109
+ logPricingRefreshError("audit log write failed", err);
110
+ }
111
+ }
112
+
113
+ function auditPricingRefreshFailure(err: unknown): void {
114
+ try {
115
+ createLogEntry({
116
+ eventType: "pricing.refresh.failed",
117
+ newValue: scrubSecrets(err instanceof Error ? err.message : String(err)),
118
+ });
119
+ } catch (auditErr) {
120
+ logPricingRefreshError("failure audit log write failed", auditErr);
121
+ }
122
+ }
123
+
124
+ export async function refreshPricingFromModelsDev(
125
+ opts: RefreshPricingOptions = {},
126
+ ): Promise<PricingRefreshResult> {
127
+ const fetchImpl = opts.fetchImpl ?? fetch;
128
+ const now = opts.now ?? Date.now();
129
+ const headers: Record<string, string> = lastETag ? { "If-None-Match": lastETag } : {};
130
+
131
+ const response = await fetchImpl(MODELSDEV_API_URL, { headers });
132
+ if (response.status === 304) {
133
+ const result: PricingRefreshResult = {
134
+ status: "not_modified",
135
+ candidateRows: 0,
136
+ inserted: 0,
137
+ unchanged: 0,
138
+ pruned: 0,
139
+ etag: lastETag ?? undefined,
140
+ };
141
+ auditPricingRefresh(result);
142
+ logPricingRefresh("models.dev returned 304; pricing rows unchanged");
143
+ return result;
144
+ }
145
+ if (!response.ok) {
146
+ throw new Error(`models.dev returned HTTP ${response.status}`);
147
+ }
148
+
149
+ const cache = (await response.json()) as ModelsDevCache;
150
+ const etag = response.headers.get("etag");
151
+ const rows = buildModelsDevSeedRows(cache);
152
+ const { inserted, unchanged } = insertChangedPricingRows(rows, now);
153
+ const pruned = prunePricingHistory(2);
154
+ lastETag = etag;
155
+
156
+ const result: PricingRefreshResult = {
157
+ status: "refreshed",
158
+ candidateRows: rows.length,
159
+ inserted,
160
+ unchanged,
161
+ pruned,
162
+ etag: lastETag ?? undefined,
163
+ };
164
+ auditPricingRefresh(result);
165
+ logPricingRefresh(
166
+ `refreshed ${rows.length} candidate row(s); inserted=${inserted}; unchanged=${unchanged}; pruned=${pruned}`,
167
+ );
168
+ return result;
169
+ }
170
+
171
+ async function runPricingRefreshSafely(): Promise<void> {
172
+ try {
173
+ await refreshPricingFromModelsDev();
174
+ } catch (err) {
175
+ logPricingRefreshError("refresh failed", err);
176
+ auditPricingRefreshFailure(err);
177
+ }
178
+ }
179
+
180
+ export function startPricingRefreshLoop(): void {
181
+ if (refreshLoopStarted) return;
182
+ refreshLoopStarted = true;
183
+
184
+ void runPricingRefreshSafely();
185
+ const interval = setInterval(() => {
186
+ void runPricingRefreshSafely();
187
+ }, PRICING_REFRESH_INTERVAL_MS);
188
+ interval.unref?.();
189
+ }
@@ -2,7 +2,9 @@
2
2
  * Phase 2 of the cost-tracking plan — seed the `pricing` table at server boot.
3
3
  *
4
4
  * The vendored models.dev snapshot at `src/be/modelsdev-cache.json` is the
5
- * single source of truth for per-token rates. We project it into rows keyed by
5
+ * cold-start fallback for per-token rates. Runtime freshness is owned by
6
+ * `src/be/pricing-refresh.ts`, which fetches models.dev after boot and inserts
7
+ * newer effective rows when prices change. We project both sources into rows keyed by
6
8
  * `(provider, model, token_class)` so the recompute path in
7
9
  * `src/http/session-data.ts` can rebuild USD from tokens regardless of which
8
10
  * adapter wrote the row.
@@ -74,7 +76,7 @@ const ANTHROPIC_SHORTNAME_TO_MODELSDEV: Record<string, string> = {
74
76
  haiku: "claude-haiku-4-5",
75
77
  };
76
78
 
77
- interface PricingSeedRow {
79
+ export interface PricingSeedRow {
78
80
  provider: PricingProvider;
79
81
  model: string;
80
82
  tokenClass: PricingTokenClass;
@@ -127,7 +129,7 @@ function projectCostBlock(
127
129
  * "what the adapter writes for `model`" and "what models.dev keys by" is
128
130
  * explicit and auditable.
129
131
  */
130
- function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[] {
132
+ export function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[] {
131
133
  const rows: PricingSeedRow[] = [];
132
134
 
133
135
  // ---- Anthropic / claude family ----------------------------------------
@@ -34,6 +34,42 @@ export const IDENTITY_MD_PATH = "/workspace/IDENTITY.md";
34
34
  export const TOOLS_MD_PATH = "/workspace/TOOLS.md";
35
35
  export const HEARTBEAT_MD_PATH = "/workspace/HEARTBEAT.md";
36
36
  export const SETUP_SCRIPT_PATH = "/workspace/start-up.sh";
37
+
38
+ // ──────────────────────────────────────────────────────────────────────────
39
+ // Identity-file baseline hashes — prevents session-end sync from clobbering
40
+ // DB-side edits made by Lead (via update-profile) during a running session.
41
+ //
42
+ // Flow:
43
+ // 1. Runner writes DB content → /workspace/*.md at session start.
44
+ // 2. Runner records SHA-256 hashes of the written content (the "baselines").
45
+ // 3. At session end, sync compares current file hash against its baseline.
46
+ // - Hash matches → file untouched by the agent → skip sync (preserves
47
+ // any DB-side edits Lead made during the session).
48
+ // - Hash differs → agent modified the file → sync it back to DB.
49
+ // ──────────────────────────────────────────────────────────────────────────
50
+ export const IDENTITY_BASELINES_PATH = "/tmp/identity-baselines.json";
51
+
52
+ export type IdentityBaselines = Record<string, string>;
53
+
54
+ export function contentSha256(content: string): string {
55
+ return new Bun.CryptoHasher("sha256").update(content).digest("hex");
56
+ }
57
+
58
+ export async function writeIdentityBaselines(baselines: IdentityBaselines): Promise<void> {
59
+ await Bun.write(IDENTITY_BASELINES_PATH, JSON.stringify(baselines));
60
+ }
61
+
62
+ export async function readIdentityBaselines(
63
+ readFile: FileReader = readFileIfExists,
64
+ ): Promise<IdentityBaselines | null> {
65
+ try {
66
+ const raw = await readFile(IDENTITY_BASELINES_PATH);
67
+ if (!raw) return null;
68
+ return JSON.parse(raw) as IdentityBaselines;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
37
73
  /**
38
74
  * Claude Code's personal-file CLAUDE.md path. This is what the Claude plugin
39
75
  * Stop hook reads and owns — the runner only uses it as a backstop for an
@@ -135,18 +171,27 @@ export function extractSetupScriptContent(raw: string): string | null {
135
171
  * the trim / max-length guards and the SOUL/IDENTITY min-length guard. Returns
136
172
  * an empty object when nothing is syncable (callers should skip the POST).
137
173
  * `undefined` inputs mean the file was absent.
174
+ *
175
+ * When `baselines` is provided, skips any field whose content hash matches the
176
+ * baseline (i.e. the file was not modified during the session). This prevents
177
+ * session-end sync from clobbering DB-side edits made by Lead.
138
178
  */
139
- export function buildIdentityPayload(files: {
140
- soulMd?: string;
141
- identityMd?: string;
142
- toolsMd?: string;
143
- heartbeatMd?: string;
144
- }): Record<string, string> {
179
+ export function buildIdentityPayload(
180
+ files: {
181
+ soulMd?: string;
182
+ identityMd?: string;
183
+ toolsMd?: string;
184
+ heartbeatMd?: string;
185
+ },
186
+ baselines?: IdentityBaselines | null,
187
+ ): Record<string, string> {
145
188
  const updates: Record<string, string> = {};
146
189
 
147
190
  if (files.soulMd !== undefined) {
148
191
  const content = files.soulMd;
149
- if (content.trim() && content.length <= MAX_FILE_LENGTH) {
192
+ if (baselines?.soulMd && contentSha256(content) === baselines.soulMd) {
193
+ // File unchanged during session — skip to preserve Lead's DB edits
194
+ } else if (content.trim() && content.length <= MAX_FILE_LENGTH) {
150
195
  if (content.length < IDENTITY_FILE_MIN_LENGTH) {
151
196
  console.error(
152
197
  `[profile-sync] Skipping SOUL.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
@@ -159,7 +204,9 @@ export function buildIdentityPayload(files: {
159
204
 
160
205
  if (files.identityMd !== undefined) {
161
206
  const content = files.identityMd;
162
- if (content.trim() && content.length <= MAX_FILE_LENGTH) {
207
+ if (baselines?.identityMd && contentSha256(content) === baselines.identityMd) {
208
+ // File unchanged during session — skip to preserve Lead's DB edits
209
+ } else if (content.trim() && content.length <= MAX_FILE_LENGTH) {
163
210
  if (content.length < IDENTITY_FILE_MIN_LENGTH) {
164
211
  console.error(
165
212
  `[profile-sync] Skipping IDENTITY.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
@@ -172,14 +219,18 @@ export function buildIdentityPayload(files: {
172
219
 
173
220
  if (files.toolsMd !== undefined) {
174
221
  const content = files.toolsMd;
175
- if (content.trim() && content.length <= MAX_FILE_LENGTH) {
222
+ if (baselines?.toolsMd && contentSha256(content) === baselines.toolsMd) {
223
+ // File unchanged during session — skip
224
+ } else if (content.trim() && content.length <= MAX_FILE_LENGTH) {
176
225
  updates.toolsMd = content;
177
226
  }
178
227
  }
179
228
 
180
229
  if (files.heartbeatMd !== undefined) {
181
230
  const content = files.heartbeatMd;
182
- if (content.length <= MAX_FILE_LENGTH) {
231
+ if (baselines?.heartbeatMd && contentSha256(content) === baselines.heartbeatMd) {
232
+ // File unchanged during session — skip
233
+ } else if (content.length <= MAX_FILE_LENGTH) {
183
234
  updates.heartbeatMd = content;
184
235
  }
185
236
  }
@@ -205,6 +256,12 @@ async function readFileIfExists(path: string): Promise<string | undefined> {
205
256
  * Collect the profile-update POST bodies to send. Each entry is one POST.
206
257
  * `fields` selects which groups to include. The file reader is injectable so
207
258
  * the field-selection / guard logic can be unit-tested without touching the FS.
259
+ *
260
+ * When `changeSource` is `"session_sync"`, loads baseline hashes written at
261
+ * session start and skips identity fields whose content hasn't changed — this
262
+ * prevents blind-overwriting DB-side edits made by Lead during the session.
263
+ * On-edit syncs (`"self_edit"`) bypass baselines entirely since the agent
264
+ * explicitly changed the file and the new content should propagate.
208
265
  */
209
266
  export async function collectProfilePayloads(
210
267
  fields: ProfileSyncField[],
@@ -214,13 +271,18 @@ export async function collectProfilePayloads(
214
271
  ): Promise<ProfilePayload[]> {
215
272
  const payloads: ProfilePayload[] = [];
216
273
 
274
+ const baselines = changeSource === "session_sync" ? await readIdentityBaselines(readFile) : null;
275
+
217
276
  if (fields.includes("identity")) {
218
- const updates = buildIdentityPayload({
219
- soulMd: await readFile(SOUL_MD_PATH),
220
- identityMd: await readFile(IDENTITY_MD_PATH),
221
- toolsMd: await readFile(TOOLS_MD_PATH),
222
- heartbeatMd: await readFile(HEARTBEAT_MD_PATH),
223
- });
277
+ const updates = buildIdentityPayload(
278
+ {
279
+ soulMd: await readFile(SOUL_MD_PATH),
280
+ identityMd: await readFile(IDENTITY_MD_PATH),
281
+ toolsMd: await readFile(TOOLS_MD_PATH),
282
+ heartbeatMd: await readFile(HEARTBEAT_MD_PATH),
283
+ },
284
+ baselines,
285
+ );
224
286
  if (Object.keys(updates).length > 0) {
225
287
  payloads.push({ label: "identity", body: { ...updates, changeSource } });
226
288
  }
@@ -229,7 +291,11 @@ export async function collectProfilePayloads(
229
291
  if (fields.includes("claude")) {
230
292
  const raw = await readFile(claudeMdPath);
231
293
  if (raw?.trim() && raw.length <= MAX_FILE_LENGTH) {
232
- payloads.push({ label: "claude", body: { claudeMd: raw, changeSource } });
294
+ if (baselines?.claudeMd && contentSha256(raw) === baselines.claudeMd) {
295
+ // CLAUDE.md unchanged during session — skip to preserve Lead's DB edits
296
+ } else {
297
+ payloads.push({ label: "claude", body: { claudeMd: raw, changeSource } });
298
+ }
233
299
  }
234
300
  }
235
301
 
@@ -57,7 +57,12 @@ import { validateJsonSchema } from "../workflows/json-schema-validator.ts";
57
57
  import { interpolate } from "../workflows/template.ts";
58
58
  import { buildContextPreamble, buildResumeContextPreamble } from "./context-preamble.ts";
59
59
  import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
60
- import { resolveClaudeMdPath, syncProfileFilesToServer } from "./profile-sync.ts";
60
+ import {
61
+ contentSha256,
62
+ resolveClaudeMdPath,
63
+ syncProfileFilesToServer,
64
+ writeIdentityBaselines,
65
+ } from "./profile-sync.ts";
61
66
  import {
62
67
  buildCredStatusReport,
63
68
  buildLatestModelReport,
@@ -4307,6 +4312,23 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4307
4312
  }
4308
4313
  }
4309
4314
 
4315
+ // Record baseline hashes of identity files as written from DB. Session-end
4316
+ // sync compares current file content against these baselines: unchanged files
4317
+ // are skipped, which prevents clobbering DB-side edits made by Lead via
4318
+ // update-profile during the running session.
4319
+ try {
4320
+ const baselines: Record<string, string> = {};
4321
+ if (agentSoulMd) baselines.soulMd = contentSha256(agentSoulMd);
4322
+ if (agentIdentityMd) baselines.identityMd = contentSha256(agentIdentityMd);
4323
+ if (agentToolsMd) baselines.toolsMd = contentSha256(agentToolsMd);
4324
+ if (agentHeartbeatMd) baselines.heartbeatMd = contentSha256(agentHeartbeatMd);
4325
+ if (agentClaudeMd) baselines.claudeMd = contentSha256(agentClaudeMd);
4326
+ await writeIdentityBaselines(baselines);
4327
+ console.log(`[${role}] Recorded identity file baselines for session-end sync`);
4328
+ } catch {
4329
+ // Non-fatal — worst case, session-end sync proceeds as before (blind overwrite)
4330
+ }
4331
+
4310
4332
  // ========== Boot-time skill load (signature-gated, replaces the standalone
4311
4333
  // skill-fetch + FS sync blocks). The polling loop below calls the same
4312
4334
  // helper per task to hot-reload skills mid-flight. Skipped for
package/src/hooks/hook.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  postRatings,
10
10
  type RetrievalRow,
11
11
  } from "../be/memory/raters/llm";
12
+ import { contentSha256, readIdentityBaselines } from "../commands/profile-sync";
12
13
  import type { Agent } from "../types";
13
14
  import { getApiKey } from "../utils/api-key";
14
15
  import { getMcpBaseUrl } from "../utils/constants";
@@ -581,7 +582,12 @@ export async function handleHook(): Promise<void> {
581
582
  const IDENTITY_FILE_MIN_LENGTH = 500;
582
583
 
583
584
  /**
584
- * Sync SOUL.md and IDENTITY.md content back to the server
585
+ * Sync SOUL.md and IDENTITY.md content back to the server.
586
+ *
587
+ * When `changeSource` is `"session_sync"` (the Stop-hook default), loads
588
+ * baseline hashes written at session start and skips any file whose content
589
+ * hasn't changed. This prevents the session-end sync from clobbering DB-side
590
+ * edits that Lead made via `update-profile` during the running session.
585
591
  */
586
592
  const syncIdentityFilesToServer = async (
587
593
  agentId: string,
@@ -589,12 +595,16 @@ export async function handleHook(): Promise<void> {
589
595
  ): Promise<void> => {
590
596
  if (!mcpConfig) return;
591
597
 
598
+ const baselines = changeSource === "session_sync" ? await readIdentityBaselines() : null;
599
+
592
600
  const updates: Record<string, string> = {};
593
601
 
594
602
  const soulFile = Bun.file(SOUL_MD_PATH);
595
603
  if (await soulFile.exists()) {
596
604
  const content = await soulFile.text();
597
- if (content.trim() && content.length <= 65536) {
605
+ if (baselines?.soulMd && contentSha256(content) === baselines.soulMd) {
606
+ // Unchanged during session — skip to preserve Lead's DB edits
607
+ } else if (content.trim() && content.length <= 65536) {
598
608
  if (content.length < IDENTITY_FILE_MIN_LENGTH) {
599
609
  console.error(
600
610
  `[hook] Skipping SOUL.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
@@ -608,7 +618,9 @@ export async function handleHook(): Promise<void> {
608
618
  const identityFile = Bun.file(IDENTITY_MD_PATH);
609
619
  if (await identityFile.exists()) {
610
620
  const content = await identityFile.text();
611
- if (content.trim() && content.length <= 65536) {
621
+ if (baselines?.identityMd && contentSha256(content) === baselines.identityMd) {
622
+ // Unchanged during session — skip
623
+ } else if (content.trim() && content.length <= 65536) {
612
624
  if (content.length < IDENTITY_FILE_MIN_LENGTH) {
613
625
  console.error(
614
626
  `[hook] Skipping IDENTITY.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
@@ -622,7 +634,9 @@ export async function handleHook(): Promise<void> {
622
634
  const toolsMdFile = Bun.file(TOOLS_MD_PATH);
623
635
  if (await toolsMdFile.exists()) {
624
636
  const content = await toolsMdFile.text();
625
- if (content.trim() && content.length <= 65536) {
637
+ if (baselines?.toolsMd && contentSha256(content) === baselines.toolsMd) {
638
+ // Unchanged during session — skip
639
+ } else if (content.trim() && content.length <= 65536) {
626
640
  updates.toolsMd = content;
627
641
  }
628
642
  }
@@ -630,7 +644,9 @@ export async function handleHook(): Promise<void> {
630
644
  const heartbeatFile = Bun.file(HEARTBEAT_MD_PATH);
631
645
  if (await heartbeatFile.exists()) {
632
646
  const content = await heartbeatFile.text();
633
- if (content.length <= 65536) {
647
+ if (baselines?.heartbeatMd && contentSha256(content) === baselines.heartbeatMd) {
648
+ // Unchanged during session — skip
649
+ } else if (content.length <= 65536) {
634
650
  updates.heartbeatMd = content;
635
651
  }
636
652
  }
package/src/http/index.ts CHANGED
@@ -451,6 +451,8 @@ try {
451
451
  try {
452
452
  const { seedPricingFromModelsDev } = await import("../be/seed-pricing");
453
453
  seedPricingFromModelsDev();
454
+ const { startPricingRefreshLoop } = await import("../be/pricing-refresh");
455
+ startPricingRefreshLoop();
454
456
  } catch (err) {
455
457
  console.error("[startup] Failed to seed pricing rows:", err);
456
458
  }
@@ -111,6 +111,11 @@ function defaultOpencodeSkillsDir(): string {
111
111
  return join(process.env.HOME ?? "/home/worker", ".opencode", "skills");
112
112
  }
113
113
  const MODEL_CACHE_REFRESH_TIMEOUT_MS = 15_000;
114
+ // opencode cold-start on E2B disk regularly exceeds the SDK's 5s default
115
+ // server-start timeout (@opencode-ai/sdk dist/server.js), failing the spawn with
116
+ // "Timeout waiting for server to start after 5000ms". Override via
117
+ // OPENCODE_SERVER_TIMEOUT_MS.
118
+ const DEFAULT_SERVER_START_TIMEOUT_MS = 30_000;
114
119
 
115
120
  function isOpenRouterModel(model: string | undefined): boolean {
116
121
  return Boolean(model?.toLowerCase().startsWith("openrouter/"));
@@ -720,6 +725,7 @@ export class OpencodeAdapter implements ProviderAdapter {
720
725
  ({ client, server } = await createOpencode({
721
726
  hostname: "127.0.0.1",
722
727
  port: 0,
728
+ timeout: Number(process.env.OPENCODE_SERVER_TIMEOUT_MS) || DEFAULT_SERVER_START_TIMEOUT_MS,
723
729
  config: opencodeConfig,
724
730
  }));
725
731
  } finally {
@@ -1,16 +1,32 @@
1
1
  # Pricing sources
2
2
 
3
- This page lists the sources that feed the `pricing` table at server boot.
4
- Operators bumping a rate by hand should also update this file.
3
+ This page lists the sources that feed the `pricing` table. Operators bumping a
4
+ rate by hand should also update this file.
5
5
 
6
- ## Primary: vendored models.dev snapshot
6
+ ## Primary pricing freshness: runtime models.dev refresh
7
7
 
8
- - **Source-of-truth path**: `src/be/modelsdev-cache.json`
8
+ - **Runtime module**: `src/be/pricing-refresh.ts`
9
+ - **Upstream**: `https://models.dev/api.json`, fetched with `If-None-Match`.
10
+ - **Boot wiring**: after `seedPricingFromModelsDev()`, the API server starts one
11
+ non-blocking refresh and then repeats every 12 hours with `setInterval`.
12
+ - **Update rule**: project upstream through `buildModelsDevSeedRows()` and insert
13
+ a new `effective_from=Date.now()` row only when the model/token class is new
14
+ or the active price changed. Identical prices are no-ops.
15
+ - **Growth bound**: after each refresh, keep only the latest two rows per
16
+ `(provider, model, token_class)` triple.
17
+ - **Pinned local entries**: safe by construction. The runtime refresh only adds
18
+ pricing rows; it does not rewrite or delete the committed snapshot.
19
+
20
+ ## Fallback/UI catalog: vendored models.dev snapshot
21
+
22
+ - **Fallback path**: `src/be/modelsdev-cache.json`
9
23
  - **UI compatibility path**: `ui/src/lib/modelsdev-cache.json` symlinks to the
10
24
  backend snapshot so existing UI imports keep working.
11
25
  - **Loaded by**: `src/be/modelsdev-cache.ts` → `src/be/seed-pricing.ts` →
12
26
  `seedPricingFromModelsDev()`,
13
27
  called from `src/server.ts` after `initDb`.
28
+ - **Role**: cold-start fallback seed for pricing when models.dev is unavailable,
29
+ plus the UI model-picker source for names, labels, and context windows.
14
30
  - **Projection rules** (see the same module for code-level detail):
15
31
  - Anthropic models → rows under `provider='claude'` AND `provider='claude-managed'`.
16
32
  Shortnames (`opus`, `sonnet`, `haiku`) ALSO get rows keyed by the current
@@ -22,12 +38,13 @@ Operators bumping a rate by hand should also update this file.
22
38
  stripped name and the full `google/...` id) so internal-ai callers find
23
39
  a hit either way.
24
40
 
25
- - **Refresh procedure** (the only place to update the snapshot):
41
+ - **Snapshot refresh procedure**:
26
42
  - Run `bun run scripts/refresh-modelsdev-pricing.ts` (Phase 2 — adds the
27
43
  script). It fetches the latest snapshot from models.dev, diffs against
28
44
  the vendored copy, prints a summary, and writes the new file.
29
45
  - Commit the regenerated `src/be/modelsdev-cache.json` together with a bump
30
- note in the PR description.
46
+ note in the PR description. This is no longer the pricing freshness path;
47
+ use it when the fallback/UI catalog needs new labels or context-window data.
31
48
 
32
49
  ## Manual overrides
33
50
 
@@ -50,6 +67,7 @@ no input/output pricing rows at the lookup time, the row is persisted with
50
67
  `costSource='unpriced'` (rather than 'harness'). The UI surfaces this as a
51
68
  yellow badge.
52
69
 
53
- To fix: either add the model to `src/be/modelsdev-cache.json` (preferred the
54
- upstream snapshot probably needs refreshing) or add a manual override row via
55
- the existing admin route `POST /api/pricing`.
70
+ To fix: first check whether the runtime refresh is failing. If the model must
71
+ also appear in the UI picker or cold-start fallback, add it to
72
+ `src/be/modelsdev-cache.json`; otherwise add a manual override row via the
73
+ existing admin route `POST /api/pricing`.
package/src/server.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import pkg from "../package.json";
3
3
  import { initDb } from "./be/db";
4
+ import { startPricingRefreshLoop } from "./be/pricing-refresh";
4
5
  import { seedPricingFromModelsDev } from "./be/seed-pricing";
5
6
  import { registerCancelTaskTool } from "./tools/cancel-task";
6
7
  import { registerContextDiffTool } from "./tools/context-diff";
@@ -172,6 +173,7 @@ export function createServer() {
172
173
  // call on every boot. See src/be/seed-pricing.ts for the projection logic
173
174
  // and the manual-override constants for runtime-fee / ACU pricing.
174
175
  seedPricingFromModelsDev();
176
+ startPricingRefreshLoop();
175
177
 
176
178
  const server = new McpServer(
177
179
  {