@stage-labs/metro 0.1.0-beta.165 → 0.1.0-beta.167

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.165",
3
+ "version": "0.1.0-beta.167",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.165",
3
+ "version": "0.1.0-beta.167",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -11,6 +11,12 @@ You are the main thread of a Claude Code session that is connected to chat throu
11
11
 
12
12
  **Never wait on the terminal.** `AskUserQuestion`, plan mode and anything that blocks for a person at the keyboard are denied everywhere, main thread and subagents alike. When something is ambiguous, do what does not depend on it, choose the most reasonable reading, say so in your reply, and if a real decision is needed ask over chat with `send` and carry on. Tool-approval prompts reach the chat too; the person answers `yes <id>` or `no <id>` there.
13
13
 
14
+ **Finish what was asked.** You work on your own. Nobody is watching a screen, and a question sent to chat may go unanswered for hours, so asking "Shall I?" about work already requested simply stops it. Anything reversible that follows from the request, do. Stop only for a destructive action or a genuine change of scope. Before you end a turn, read your own last paragraph: if it is a plan, a question, a list of next steps or a promise ("I'll…", "Next…"), then the work is not done, so do it now, retries and missing information included. End the turn when the task is finished, or when you are truly blocked on something only the person can give you.
15
+
16
+ The exception is a person describing a problem, asking a question or thinking out loud. There the answer IS the deliverable: reply, and change nothing until they ask.
17
+
18
+ **The whole request is the deliverable.** Do not quietly make it smaller, larger or different. Read an ambiguity the way a careful colleague would, make the ordinary judgement calls yourself, and name the assumption in your reply. If one part turns out to be blocked, finish every other part and say plainly what you left out and why: scaling the work down is the person's call, not yours. Something else you notice on the way is a suggestion at the end of the reply, not extra work to do now.
19
+
14
20
  **Report like a relay.** A worker's report is the only evidence you have, so pass on what it verified and say plainly what it did not. Short messages on chat: lead with the outcome, one idea per sentence, no tool names, no file paths unless the person needs them. Send a file out with `create_upload` (the curl line it hands back runs in a worker) and then `send` with the upload id.
15
21
 
16
22
  **Keep going.** Long tasks run as background agents and loops; check in over chat when something lands rather than going quiet. The session is restarted by the daemon if it exits, so an interrupted task is resumed from chat, not from memory.
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -30,7 +31,9 @@ effort: xhigh
30
31
 
31
32
  You are a worker agent. The main thread that dispatched you is an orchestrator with no file, shell or network access; it can only delegate and relay messages. That means you are where the actual work happens, and the quality of your output is the quality of the result.
32
33
 
33
- Work to completion. Do not hand back a partial answer with a suggestion that someone else finish it; there is no one else. If part of the task is genuinely blocked, complete every other part in full and say plainly what you left undone and why.
34
+ Work to completion. Do not hand back a partial answer with a suggestion that someone else finish it; there is no one else. If part of the task is genuinely blocked, complete every other part in full and say plainly what you left undone and why. Before you finish, read your own last paragraph: if it is a plan, a question or a promise about work you have not done, do that work now.
35
+
36
+ Keep the change to what the task asks for. A pre-existing bug, a performance problem or behaviour the task does not mention is a follow-up to report at the end, not something to fix now, unless the requested behaviour cannot work without it. Where the task is ambiguous, build the reading its wording and the surrounding code support best, and state that assumption. Commit tests only where the task asks for them or the repository already keeps tests for that kind of change, sized like the neighbouring test files; scratch checks you used to verify yourself need not be kept. This is about extras only: every behaviour the task does ask for, implement completely.
34
37
 
35
38
  Never call AskUserQuestion or ExitPlanMode. They are blocked by policy, because nobody is watching the terminal around the clock and a blocking prompt stalls indefinitely. When you hit an ambiguity: do everything that does not depend on it, then choose the most reasonable interpretation, state the assumption explicitly in your report, and continue.
36
39
 
@@ -44,7 +47,7 @@ export interface SetupDeps {
44
47
  env?: NodeJS.ProcessEnv;
45
48
  }
46
49
 
47
- export type Placed = 'written' | 'present' | 'missing';
50
+ export type Placed = 'written' | 'present' | 'updated' | 'missing';
48
51
  export type SettingsOutcome = 'written' | 'unchanged' | 'unreadable';
49
52
 
50
53
  export interface SetupReport {
@@ -127,11 +130,21 @@ export function guidancePath(env: NodeJS.ProcessEnv = process.env): string {
127
130
  return fileURLToPath(new URL(`../../../../plugin/${GUIDANCE}`, import.meta.url));
128
131
  }
129
132
 
130
- function ensureFile(path: string, text: string): Placed {
131
- if (existsSync(path)) return 'present';
132
- mkdirSync(dirname(path), { recursive: true });
133
+ const PRIOR_WORKER: ReadonlySet<string> = new Set(['08a0cd8710df285d6512245246bb8b658b7cfc7f3e89490514ee5376779eb2ef']);
134
+ const PRIOR_SKILL: ReadonlySet<string> = new Set(['0b3d122ed95beff09d579cf912cd4238e1db524c41fce4b314de57d6ff5908ad']);
135
+
136
+ const digest = (text: string): string => createHash('sha256').update(text).digest('hex');
137
+
138
+ export function placeFile(path: string, text: string, prior: ReadonlySet<string> = new Set()): Placed {
139
+ if (!existsSync(path)) {
140
+ mkdirSync(dirname(path), { recursive: true });
141
+ writeFileSync(path, text, { mode: 0o644 });
142
+ return 'written';
143
+ }
144
+ const current = readFileSync(path, 'utf8');
145
+ if (current === text || !prior.has(digest(current))) return 'present';
133
146
  writeFileSync(path, text, { mode: 0o644 });
134
- return 'written';
147
+ return 'updated';
135
148
  }
136
149
 
137
150
  function readSettings(path: string): Record<string, unknown> | null {
@@ -172,9 +185,11 @@ const workerPath = (dir: string): string => join(dir, 'agents', 'worker.md');
172
185
 
173
186
  function placeSkill(dir: string, guidance: string): Placed {
174
187
  if (!existsSync(guidance)) return 'missing';
175
- return ensureFile(skillPath(dir), readFileSync(guidance, 'utf8'));
188
+ return placeFile(skillPath(dir), readFileSync(guidance, 'utf8'), PRIOR_SKILL);
176
189
  }
177
190
 
191
+ const written = (placed: Placed): boolean => placed === 'written' || placed === 'updated';
192
+
178
193
  export function ensureClaudeSetup(deps: SetupDeps = {}): SetupReport {
179
194
  const dir = deps.dir ?? claudeDir();
180
195
  const agents = deps.agents ?? agentsDir();
@@ -182,12 +197,12 @@ export function ensureClaudeSetup(deps: SetupDeps = {}): SetupReport {
182
197
  const report: SetupReport = {
183
198
  privacy,
184
199
  guard: 'plugin',
185
- worker: ensureFile(workerPath(dir), WORKER_AGENT),
200
+ worker: placeFile(workerPath(dir), WORKER_AGENT, PRIOR_WORKER),
186
201
  skill: placeSkill(dir, deps.guidance ?? guidancePath(deps.env)),
187
202
  settings: applyPrivacy(dir, privacy),
188
203
  };
189
204
  syncAvailableModelsQuietly({ ...deps, dir, agents });
190
- if (report.worker === 'written' || report.skill === 'written' || report.settings === 'written')
205
+ if (written(report.worker) || written(report.skill) || report.settings === 'written')
191
206
  log.info(report, 'claude-setup: applied the Claude Code setup for a metro box');
192
207
  if (report.settings === 'unreadable') log.warn({ path: join(dir, 'settings.json') }, 'claude-setup: settings.json is not valid JSON, so the privacy settings were not written');
193
208
  return report;
@@ -0,0 +1,70 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+
4
+ export const MAIN_EFFORT = 'low';
5
+ export const SUBAGENT_EFFORT = 'max';
6
+ export const BINDING_BETA = 'thinking-binding-controls-2026-08-01';
7
+
8
+ const SUBAGENT_HEADER = 'x-claude-code-agent-id';
9
+ const SUBAGENT_MARK = 'cc_is_subagent=true';
10
+ const ABOVE_HIGH = new Set(['xhigh', 'max']);
11
+
12
+ export type RequestKind = 'main' | 'subagent' | 'other';
13
+
14
+ type Body = Record<string, unknown>;
15
+
16
+ const record = (value: unknown): Body => (isRecord(value) ? value : {});
17
+
18
+ const effortOf = (body: Body): string => {
19
+ const effort = record(body.output_config).effort;
20
+ return typeof effort === 'string' ? effort : '';
21
+ };
22
+
23
+ function header(req: IncomingMessage, name: string): string {
24
+ const raw = req.headers[name];
25
+ return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
26
+ }
27
+
28
+ function systemMarksSubagent(body: Body): boolean {
29
+ const system = body.system;
30
+ if (!Array.isArray(system)) return false;
31
+ return system.some((block) => isRecord(block) && typeof block.text === 'string' && block.text.includes(SUBAGENT_MARK));
32
+ }
33
+
34
+ const chore = (body: Body): boolean => record(body.output_config).format !== undefined || record(body.thinking).type === 'disabled';
35
+
36
+ export function requestKind(req: IncomingMessage, body: Body): RequestKind {
37
+ if (header(req, SUBAGENT_HEADER) !== '' || systemMarksSubagent(body)) return 'subagent';
38
+ return chore(body) ? 'other' : 'main';
39
+ }
40
+
41
+ export function plannedEffort(req: IncomingMessage, body: Body): string | null {
42
+ const current = effortOf(body);
43
+ if (current === '') return null;
44
+ const kind = requestKind(req, body);
45
+ if (kind === 'other') return null;
46
+ const wanted = kind === 'subagent' ? SUBAGENT_EFFORT : MAIN_EFFORT;
47
+ return wanted === current ? null : wanted;
48
+ }
49
+
50
+ export const withEffort = (body: Body, effort: string): Body => ({ ...body, output_config: { ...record(body.output_config), effort } });
51
+
52
+ export function withoutEffort(body: Body): Body {
53
+ const config = Object.fromEntries(Object.entries(record(body.output_config)).filter(([key]) => key !== 'effort'));
54
+ if (Object.keys(config).length > 0) return { ...body, output_config: config };
55
+ return Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'output_config'));
56
+ }
57
+
58
+ export function effortToApply(body: Body): string | null {
59
+ if (chore(body)) return null;
60
+ const effort = effortOf(body);
61
+ return effort === '' ? null : effort;
62
+ }
63
+
64
+ export const cappedEffort = (effort: string): string => (ABOVE_HIGH.has(effort) ? 'high' : effort);
65
+
66
+ export function withBlockBinding(body: Body): Body {
67
+ const thinking = body.thinking;
68
+ if (!isRecord(thinking) || thinking.block_binding !== undefined) return body;
69
+ return { ...body, thinking: { ...thinking, block_binding: { prefix_mismatch_behavior: 'drop_block' } } };
70
+ }
@@ -127,6 +127,15 @@ export function anthropicHeaders(req: IncomingMessage, apiKey: string): Record<s
127
127
  return headers;
128
128
  }
129
129
 
130
+ export function addBeta(headers: Record<string, string>, beta: string): Record<string, string> {
131
+ const listed = (headers['anthropic-beta'] ?? '')
132
+ .split(',')
133
+ .map((one) => one.trim())
134
+ .filter((one) => one !== '');
135
+ if (listed.includes(beta)) return headers;
136
+ return { ...headers, 'anthropic-beta': [...listed, beta].join(',') };
137
+ }
138
+
130
139
  export interface PipeOptions {
131
140
  keepalive?: boolean;
132
141
  ownCredential?: boolean;
@@ -9,7 +9,8 @@ import {
9
9
  freshAdaptations,
10
10
  type Adaptations,
11
11
  } from './bedrock.js';
12
- import { anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
12
+ import { addBeta, anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
13
+ import { BINDING_BETA, cappedEffort, effortToApply, plannedEffort, withBlockBinding, withEffort } from './effort.js';
13
14
  import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type Connection, type ModelConfig, type Route } from './model-config.js';
14
15
  import { codexCount, codexMessages, freshCodexState } from './codex.js';
15
16
  import { freshGeminiState, geminiCount, geminiMessages } from './gemini.js';
@@ -89,17 +90,41 @@ const standsInFor = (req: IncomingMessage): boolean => {
89
90
  return typeof metroKey === 'string' && metroKey !== '' && req.headers.authorization === `Bearer ${metroKey}`;
90
91
  };
91
92
 
93
+ interface Payloads {
94
+ metro: Buffer;
95
+ asSent: Buffer | null;
96
+ bound: boolean;
97
+ }
98
+
99
+ function anthropicPayloads(raw: Buffer, sent: Record<string, unknown>, shaped: Record<string, unknown>, model: string): Payloads {
100
+ const rewrite = typeof sent.model === 'string' && sent.model !== model;
101
+ const asSent = rewrite ? Buffer.from(JSON.stringify({ ...sent, model })) : raw;
102
+ const bound = withBlockBinding(shaped);
103
+ if (bound === sent) return { metro: asSent, asSent: null, bound: false };
104
+ return { metro: Buffer.from(JSON.stringify(rewrite ? { ...bound, model } : bound)), asSent, bound: bound !== shaped };
105
+ }
106
+
107
+ type Send = (payload: Buffer, headers: Record<string, string>) => Promise<Response>;
108
+
109
+ async function asAnthropicWants(send: Send, base: Record<string, string>, payloads: Payloads, connection: string, model: string): Promise<Response> {
110
+ const { metro, asSent, bound } = payloads;
111
+ const upstream = await send(metro, bound ? addBeta(base, BINDING_BETA) : base);
112
+ if (upstream.status !== 400 || asSent === null) return upstream;
113
+ await upstream.body?.cancel();
114
+ log.warn({ connection, model }, 'gateway: Anthropic refused the request metro shaped, so it was sent again as Claude Code wrote it');
115
+ return send(asSent, base);
116
+ }
117
+
92
118
  async function toAnthropic(
93
119
  req: IncomingMessage,
94
120
  res: ServerResponse,
95
121
  raw: Buffer,
96
- body: Record<string, unknown>,
122
+ sent: Record<string, unknown>,
123
+ shaped: Record<string, unknown>,
97
124
  route: Route,
98
125
  deps: GatewayDeps,
99
126
  ): Promise<void> {
100
127
  const conn = route.connection;
101
- const explicit = typeof body.model === 'string' && body.model !== route.model;
102
- const payload = explicit ? Buffer.from(JSON.stringify({ ...body, model: route.model })) : raw;
103
128
  const url = `${deps.anthropicBase ?? ANTHROPIC_BASE}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
104
129
  const key = conn.apiKey;
105
130
  if (key === '' && standsInFor(req))
@@ -109,13 +134,10 @@ async function toAnthropic(
109
134
  'Claude Code on this machine has no Anthropic login of its own; choose Bedrock, OpenRouter, Codex or Gemini on the Model page, or sign in on the Claude tab',
110
135
  );
111
136
  const watch = watchUpstream(res);
112
- const upstream = await fetch(url, {
113
- method: 'POST',
114
- headers: key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key),
115
- body: new Uint8Array(payload),
116
- signal: watch.signal,
117
- redirect: 'manual',
118
- });
137
+ const base = key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key);
138
+ const send = (payload: Buffer, headers: Record<string, string>): Promise<Response> =>
139
+ fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
140
+ const upstream = await asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
119
141
  noteRefusal(conn.label, route.model, upstream);
120
142
  noteUsageHeaders('anthropic', conn.id, upstream.headers);
121
143
  const scanner = new UsageScanner(conn.id);
@@ -160,6 +182,8 @@ const thinkingOff = (body: Record<string, unknown>): boolean => isRecord(body.th
160
182
  export function openrouterBody(body: Record<string, unknown>, model: string, zdr: boolean): Record<string, unknown> {
161
183
  const sent: Record<string, unknown> = { ...body, model };
162
184
  if (thinkingOff(body)) delete sent.thinking;
185
+ const effort = effortToApply(body);
186
+ if (effort !== null) sent.reasoning = { ...(isRecord(body.reasoning) ? body.reasoning : {}), effort: cappedEffort(effort) };
163
187
  if (!zdr) return sent;
164
188
  const provider = isRecord(body.provider) ? body.provider : {};
165
189
  return { ...sent, provider: { ...provider, zdr: true } };
@@ -180,10 +204,16 @@ async function toSubscription(req: IncomingMessage, res: ServerResponse, path: s
180
204
  else await codexMessages(req, res, body, route.model, conn, { save: saveCodexTokens, ...deps.codex }, codexState, watchUpstream(res));
181
205
  }
182
206
 
207
+ function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<string, unknown> {
208
+ const effort = plannedEffort(req, sent);
209
+ return effort === null ? sent : withEffort(sent, effort);
210
+ }
211
+
183
212
  async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
184
213
  const cfg = deps.config();
185
214
  const raw = await readBody(req);
186
- const body = parseJson(raw);
215
+ const sent = parseJson(raw);
216
+ const body = shapedFor(req, sent);
187
217
  const route = resolveRoute(requestedModel(body), cfg) ?? passthrough(body);
188
218
  const conn = route.connection;
189
219
  log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
@@ -204,7 +234,7 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
204
234
  await toSubscription(req, res, path, body, route, deps);
205
235
  return;
206
236
  }
207
- await toAnthropic(req, res, raw, body, route, deps);
237
+ await toAnthropic(req, res, raw, sent, body, route, deps);
208
238
  }
209
239
 
210
240
  function failed(res: ServerResponse, err: unknown): void {
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { ToolNames } from './codex-translate.js';
4
4
  import { CLIENT_NAME, requestId, SYSTEM_PREFIX } from './gemini-client.js';
5
+ import { cappedEffort, effortToApply } from './effort.js';
5
6
 
6
7
  type Item = Record<string, unknown>;
7
8
 
@@ -190,6 +191,8 @@ function generationConfig(body: Item): Item {
190
191
  if (typeof body.temperature === 'number') out.temperature = body.temperature;
191
192
  if (typeof body.top_p === 'number') out.topP = body.top_p;
192
193
  if (isRecord(body.thinking) && body.thinking.type === 'enabled') out.thinkingConfig = { includeThoughts: true };
194
+ const effort = effortToApply(body);
195
+ if (effort !== null) out.thinkingLevel = cappedEffort(effort);
193
196
  return out;
194
197
  }
195
198
 
@@ -10,6 +10,7 @@ import { assembleMessage, SseParser } from './codex-stream.js';
10
10
  import { GatewayError, idleMessage, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
11
11
  import type { Connection } from './model-config.js';
12
12
  import { UsageScanner } from './usage.js';
13
+ import { effortToApply, withoutEffort } from './effort.js';
13
14
 
14
15
  const PING_MS = 25_000;
15
16
  const STATUS_OF: Record<string, number> = { rate_limit_error: 429, invalid_request_error: 400, permission_error: 403, overloaded_error: 529 };
@@ -255,6 +256,13 @@ async function relayWhole(upstream: Response, res: ServerResponse, call: Call):
255
256
  res.end(JSON.stringify(message));
256
257
  }
257
258
 
259
+ const NAMES_THINKING = /thinking|thought/i;
260
+
261
+ function refuse(res: ServerResponse, model: string, status: number, text: string): void {
262
+ log.warn({ provider: 'gemini', model, status, body: text.slice(0, REFUSAL_LOG_MAX) }, 'gateway: the provider refused the request');
263
+ sendError(res, providerStatus(status), errorKind(status), refusalMessage(text, status));
264
+ }
265
+
258
266
  export async function geminiMessages(
259
267
  req: IncomingMessage,
260
268
  res: ServerResponse,
@@ -266,12 +274,20 @@ export async function geminiMessages(
266
274
  watch: Watch,
267
275
  ): Promise<void> {
268
276
  const stream = body.stream === true;
269
- const call: Call = { body, model, promptId: promptIdOf(req), watch, deps, names: new ToolNames(), conn };
270
- const upstream = await reach(call, conn, state, stream);
271
- if (!upstream.ok) {
277
+ let call: Call = { body, model, promptId: promptIdOf(req), watch, deps, names: new ToolNames(), conn };
278
+ let upstream = await reach(call, conn, state, stream);
279
+ if (upstream.status === 400 && effortToApply(body) !== null) {
272
280
  const text = await upstream.text();
273
- log.warn({ provider: 'gemini', model, status: upstream.status, body: text.slice(0, REFUSAL_LOG_MAX) }, 'gateway: the provider refused the request');
274
- sendError(res, providerStatus(upstream.status), errorKind(upstream.status), refusalMessage(text, upstream.status));
281
+ if (!NAMES_THINKING.test(text)) {
282
+ refuse(res, model, 400, text);
283
+ return;
284
+ }
285
+ log.warn({ provider: 'gemini', model }, 'gateway: Gemini refused the thinking level, so metro asked again without it');
286
+ call = { ...call, body: withoutEffort(body) };
287
+ upstream = await reach(call, conn, state, stream);
288
+ }
289
+ if (!upstream.ok) {
290
+ refuse(res, model, upstream.status, await upstream.text());
275
291
  return;
276
292
  }
277
293
  if (stream) await relayStream(upstream, res, call);
@@ -130,8 +130,14 @@ export function parseModelConfig(raw: unknown): ModelConfig {
130
130
  return { version: 2, route: connections.some((c) => c.id === route) ? route : (connections[0]?.id ?? ''), connections };
131
131
  }
132
132
 
133
+ const needsIds = (raw: unknown): boolean =>
134
+ isRecord(raw) && (!Array.isArray(raw.connections) || raw.connections.some((c: unknown) => isRecord(c) && text(c.id) === ''));
135
+
133
136
  export function readModelConfig(dir = agentsDir()): ModelConfig {
134
- return parseModelConfig(readJson<unknown>(join(dir, MODEL_FILE), null, { warn: 'model-config: model.json is unreadable, so every request goes to Anthropic until it is fixed' }));
137
+ const raw = readJson<unknown>(join(dir, MODEL_FILE), null, { warn: 'model-config: model.json is unreadable, so every request goes to Anthropic until it is fixed' });
138
+ const cfg = parseModelConfig(raw);
139
+ if (needsIds(raw)) writeModelConfig(cfg, dir);
140
+ return cfg;
135
141
  }
136
142
 
137
143
  export function writeModelConfig(cfg: ModelConfig, dir = agentsDir()): void {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.165"
2
+ "version": "0.1.0-beta.167"
3
3
  }