@stage-labs/metro 0.1.0-beta.170 → 0.1.0-beta.172

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.
Files changed (42) hide show
  1. package/dist/cli.js +7 -10
  2. package/dist/route.js +16 -8
  3. package/dist/serve.js +1 -1
  4. package/dist/service.js +1 -1
  5. package/package.json +1 -1
  6. package/runtime/marketplace/.claude-plugin/marketplace.json +1 -1
  7. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +2 -3
  8. package/runtime/node_modules/@metro-labs/core/src/stations/account-store.ts +1 -1
  9. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +14 -7
  10. package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +3 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +41 -3
  12. package/runtime/node_modules/@metro-labs/daemon/src/claude/settings.ts +11 -2
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +30 -6
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +12 -8
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +4 -4
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +18 -7
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +6 -8
  18. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +5 -2
  19. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +18 -7
  20. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +4 -6
  21. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +1 -1
  22. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-inline.ts +29 -1
  23. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-resolve.ts +4 -9
  24. package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +1 -144
  25. package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +14 -5
  26. package/runtime/node_modules/@metro-labs/telegram/src/format.ts +19 -0
  27. package/runtime/node_modules/@metro-labs/telegram/src/inbound.ts +2 -1
  28. package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +1 -45
  29. package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +13 -5
  30. package/runtime/node_modules/@metro-labs/telegram-bot/src/media-actions.ts +1 -73
  31. package/runtime/node_modules/@metro-labs/xmtp/package.json +0 -1
  32. package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +2 -173
  33. package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +2 -41
  34. package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +1 -129
  35. package/runtime/node_modules/@metro-labs/xmtp/src/emit-core.ts +13 -5
  36. package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +1 -10
  37. package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +3 -4
  38. package/runtime/node_modules/@metro-labs/xmtp/src/labels.ts +1 -19
  39. package/runtime/runtime.json +1 -1
  40. package/runtime/stations.json +0 -1
  41. package/runtime/node_modules/@metro-labs/xmtp/src/actions-push.ts +0 -87
  42. package/runtime/node_modules/@metro-labs/xmtp/src/push.ts +0 -349
package/dist/cli.js CHANGED
@@ -9,15 +9,15 @@ import { service, serviceStopHint } from './service.js';
9
9
  import { currentVersion } from './version.js';
10
10
  const USAGE = `metro — run your agent on this machine
11
11
 
12
- metro serve [--port <n>] [--owner <address>]
12
+ metro serve [--port <n>] [--owner <organization id>]
13
13
  run the daemon: the agent, its channels and its connectors live in
14
14
  ~/.metro/agents here, and it is published through Tailscale Funnel at a
15
15
  permanent https://<machine>.<tailnet>.ts.net (Tailscale installed and
16
16
  signed in on this machine); the page at metro.box manages it through the
17
- link it prints; --owner names the one wallet that may sign in, needed on
18
- the first start only; Stop on the page parks the daemon and keeps the
17
+ link it prints; --owner names the organization whose members may sign
18
+ in, needed on the first start only; Stop on the page parks the daemon and keeps the
19
19
  address, so Start works from the page too
20
- metro service install [--port <n>] [--owner <address>]
20
+ metro service install [--port <n>] [--owner <organization id>]
21
21
  run metro serve as a service (systemd on Linux, launchd on macOS): it
22
22
  starts at boot and after a crash, so the page can stop, start and
23
23
  restart it with no shell; metro service uninstall and status as well
@@ -26,18 +26,15 @@ const USAGE = `metro — run your agent on this machine
26
26
  follow this machine's inbound events, one JSON line each
27
27
  metro whoami print the agent this machine runs
28
28
  metro claude [args...]
29
- open Claude Code with the metro channel; every argument is passed through
30
- the same, with inference on Amazon Bedrock through a local proxy so the
31
- channel still works (needs AWS_BEARER_TOKEN_BEDROCK and AWS_REGION)
29
+ open Claude Code with the metro channel and the metro MCP server, its
30
+ inference routed through the daemon's model gateway (the Model page);
31
+ every argument is passed through the same
32
32
  metro update update to the newest published version (--check only reports)
33
33
  metro version print this CLI's version
34
34
 
35
35
  METRO_WEBHOOK_PORT the daemon's port (default 8420)
36
36
  METRO_AGENTS_DIR where the agents live (default ~/.metro/agents)
37
37
  METRO_AGENT_KEY the key metro tail presents, instead of the agent file's
38
- METRO_BEDROCK_MODEL
39
- send every request to this Bedrock model id (default: derive from the
40
- model Claude Code asks for, e.g. eu.anthropic.claude-sonnet-4-6)
41
38
  METRO_RUNTIME_DIR run the daemon from this directory instead of the bundled one
42
39
  `;
43
40
  async function stopDaemon() {
package/dist/route.js CHANGED
@@ -1,19 +1,27 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { agentsDir } from './local.js';
4
- const PROVIDERS = ['bedrock', 'openrouter', 'codex', 'gemini'];
4
+ const PROVIDERS = new Set(['bedrock', 'openrouter', 'codex', 'gemini']);
5
+ const text = (value) => (typeof value === 'string' ? value : '');
6
+ function routeIn(cfg) {
7
+ if (typeof cfg !== 'object' || cfg === null)
8
+ return null;
9
+ const { route, connections } = cfg;
10
+ if (!Array.isArray(connections))
11
+ return null;
12
+ const conn = connections.find((c) => typeof c === 'object' && c !== null && c.id === route);
13
+ if (typeof conn !== 'object' || conn === null)
14
+ return null;
15
+ const provider = text(conn.provider);
16
+ const model = text(conn.model);
17
+ return PROVIDERS.has(provider) && model !== '' ? `${provider}:${model}` : null;
18
+ }
5
19
  export function currentRoute(dir = agentsDir()) {
6
20
  const path = join(dir, 'model.json');
7
21
  if (!existsSync(path))
8
22
  return null;
9
23
  try {
10
- const cfg = JSON.parse(readFileSync(path, 'utf8'));
11
- const provider = PROVIDERS.find((p) => p === cfg.provider);
12
- if (provider === undefined)
13
- return null;
14
- const block = cfg[provider];
15
- const model = typeof block === 'object' && block !== null ? block.model : undefined;
16
- return typeof model === 'string' && model !== '' ? `${provider}:${model}` : null;
24
+ return routeIn(JSON.parse(readFileSync(path, 'utf8')));
17
25
  }
18
26
  catch {
19
27
  return null;
package/dist/serve.js CHANGED
@@ -12,7 +12,7 @@ const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
12
12
  const PORT_FLAG = /^--port=(.*)$/;
13
13
  const OWNER_FLAG = /^--owner=(.*)$/;
14
14
  const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
15
- const USAGE = 'usage: metro serve [--port <n>] [--owner <address>]';
15
+ const USAGE = 'usage: metro serve [--port <n>] [--owner <organization id>]';
16
16
  const TUNNEL_FLAG = /^--(no-)?tunnel(=.*)?$/;
17
17
  const TAILSCALE_APP = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
18
18
  function portOf(raw) {
package/dist/service.js CHANGED
@@ -5,7 +5,7 @@ import { dirname, join, resolve } from 'node:path';
5
5
  import { serveLockedBy } from './control.js';
6
6
  import { findBun } from './runtime.js';
7
7
  import { findTailscale, parseServeArgs, requireOwner } from './serve.js';
8
- const USAGE = 'usage: metro service install [--port <n>] [--owner <address>] | uninstall | status';
8
+ const USAGE = 'usage: metro service install [--port <n>] [--owner <organization id>] | uninstall | status';
9
9
  const SERVICE = 'metro';
10
10
  const LABEL = 'box.metro.serve';
11
11
  const CARRIED = /^(METRO_.*|XDG_CACHE_HOME|PATH|HOME)$/;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.170",
3
+ "version": "0.1.0-beta.172",
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": {
@@ -6,7 +6,7 @@
6
6
  {
7
7
  "name": "metro",
8
8
  "source": "./plugin",
9
- "description": "Sign in as one metro agent and use the connectors it holds as MCP servers through metro's relay, credentials injected server-side."
9
+ "description": "Every connector on this box as its own MCP server through metro's local relay, the orchestrator guard, and the standing rules loaded at session start."
10
10
  }
11
11
  ]
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.170",
3
+ "version": "0.1.0-beta.172",
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",
@@ -13,6 +13,5 @@
13
13
  "mcp",
14
14
  "connectors",
15
15
  "relay"
16
- ],
17
- "skills": "./skills/"
16
+ ]
18
17
  }
@@ -26,7 +26,7 @@ export function makeAccountStore<T extends { id: string }>(
26
26
  function loadAccounts(): T[] {
27
27
  if (!existsSync(opts.file))
28
28
  return die(
29
- `no accounts file ${opts.file} — the daemon writes it from the database at boot; set DATABASE_URL and populate the accounts table`,
29
+ `no accounts file ${opts.file} — the daemon writes it from ~/.metro/agents/agent.json at boot; attach an account to this station from the page first`,
30
30
  );
31
31
  chmodIfExists(opts.file);
32
32
  let raw: T[];
@@ -44,7 +44,7 @@ export class InboundRelay {
44
44
  private readonly pendingAttachments = new Map<string, PendingMsg>();
45
45
  private readonly seenEvents = new Map<string, number>();
46
46
  private readonly allowedLines = new Set<string>();
47
- private readonly pendingPermissions = new Set<string>();
47
+ private readonly pendingPermissions = new Map<string, string>();
48
48
  private readonly sentIds = new Set<string>();
49
49
  private lastLine: string | undefined;
50
50
 
@@ -56,9 +56,13 @@ export class InboundRelay {
56
56
  return this.lastLine;
57
57
  }
58
58
 
59
- registerPermission(requestId: string): void {
60
- this.pendingPermissions.add(requestId);
61
- capSet(this.pendingPermissions, PENDING_PERMISSIONS_MAX);
59
+ registerPermission(requestId: string, line: string): void {
60
+ this.pendingPermissions.set(requestId, line);
61
+ while (this.pendingPermissions.size > PENDING_PERMISSIONS_MAX) {
62
+ const oldest = this.pendingPermissions.keys().next();
63
+ if (oldest.done) break;
64
+ this.pendingPermissions.delete(oldest.value);
65
+ }
62
66
  }
63
67
 
64
68
  noteSent(messageId: string): void {
@@ -104,6 +108,7 @@ export class InboundRelay {
104
108
  from: ctx.from,
105
109
  station: ctx.station,
106
110
  ...senderMeta(ctx),
111
+ ...ctx.reply,
107
112
  kind: note.kind,
108
113
  mime: p.mime ?? '',
109
114
  name: note.name,
@@ -149,6 +154,7 @@ export class InboundRelay {
149
154
  line_name: e.lineName,
150
155
  from_name: e.fromName,
151
156
  ...displayNameMeta(e.fromDisplayName),
157
+ ...e.reply,
152
158
  },
153
159
  });
154
160
  }
@@ -206,6 +212,7 @@ export class InboundRelay {
206
212
  lineName: str(ev.lineName),
207
213
  fromName: str(ev.fromName),
208
214
  fromDisplayName: str(ev.fromDisplayName),
215
+ reply: replyMeta(ev, this.sentIds),
209
216
  attachments: atts.map((a) => ({ kind: a.kind, name: a.name })),
210
217
  saved: new Set<number>(),
211
218
  timer: setTimeout(() => {
@@ -253,12 +260,12 @@ export class InboundRelay {
253
260
  });
254
261
  }
255
262
 
256
- private async handlePermissionReply(text: string): Promise<boolean> {
263
+ private async handlePermissionReply(text: string, line: string): Promise<boolean> {
257
264
  const m = PERMISSION_REPLY_RE.exec(text);
258
265
  if (m?.[1] === undefined || m[2] === undefined || !this.pendingPermissions.size)
259
266
  return false;
260
267
  const id = m[2].toLowerCase();
261
- if (!this.pendingPermissions.has(id)) return false;
268
+ if (this.pendingPermissions.get(id) !== line) return false;
262
269
  this.pendingPermissions.delete(id);
263
270
  await this.notify('notifications/claude/channel/permission', {
264
271
  request_id: id,
@@ -315,7 +322,7 @@ export class InboundRelay {
315
322
  this.deps.log('drop: empty message', base.station, base.line, str(ev.messageId));
316
323
  return;
317
324
  }
318
- if (base.evType === 'msg' && (await this.handlePermissionReply(base.text)))
325
+ if (base.evType === 'msg' && (await this.handlePermissionReply(base.text, base.line)))
319
326
  return;
320
327
  await this.notify('notifications/claude/channel', {
321
328
  content:
@@ -15,6 +15,7 @@ export interface PendingMsg {
15
15
  lineName: string;
16
16
  fromName: string;
17
17
  fromDisplayName: string;
18
+ reply: Record<string, string>;
18
19
  attachments: PendingAtt[];
19
20
  saved: Set<number>;
20
21
  timer: ReturnType<typeof setTimeout>;
@@ -30,6 +31,7 @@ export interface MediaCtx {
30
31
  lineName?: string;
31
32
  fromName?: string;
32
33
  fromDisplayName?: string;
34
+ reply?: Record<string, string>;
33
35
  }
34
36
 
35
37
  export function capSet(set: Set<string>, max: number): void {
@@ -69,6 +71,7 @@ export function takeMediaCtx(buf: PendingMsg): MediaCtx {
69
71
  lineName: buf.lineName,
70
72
  fromName: buf.fromName,
71
73
  fromDisplayName: buf.fromDisplayName,
74
+ reply: buf.reply,
72
75
  };
73
76
  buf.text = '';
74
77
  return ctx;
@@ -70,12 +70,49 @@ function sweep(now: number): void {
70
70
  }
71
71
  }
72
72
 
73
+ const CHECK_MS = 30_000;
74
+
75
+ interface Checked<T> {
76
+ at: number;
77
+ value: T;
78
+ }
79
+
80
+ const checks: { installed?: Checked<boolean>; account?: Checked<ClaudeAccount> } = {};
81
+
82
+ function remembered<T>(slot: Checked<T> | undefined, read: () => T, keep: (c: Checked<T>) => void): T {
83
+ const now = Date.now();
84
+ if (slot !== undefined && now - slot.at < CHECK_MS) return slot.value;
85
+ const value = read();
86
+ keep({ at: now, value });
87
+ return value;
88
+ }
89
+
90
+ function forgetClaudeChecks(): void {
91
+ delete checks.installed;
92
+ delete checks.account;
93
+ }
94
+
73
95
  export function claudeInstalled(): boolean {
74
- const run = spawnSync('claude', ['--version'], { stdio: 'ignore' });
75
- return run.error === undefined && run.status === 0;
96
+ return remembered(checks.installed, () => {
97
+ const run = spawnSync('claude', ['--version'], { stdio: 'ignore' });
98
+ return run.error === undefined && run.status === 0;
99
+ }, (c) => {
100
+ checks.installed = c;
101
+ });
102
+ }
103
+
104
+ interface ClaudeAccount {
105
+ signedIn: boolean;
106
+ account: string | null;
107
+ }
108
+
109
+ export function claudeAccount(): ClaudeAccount {
110
+ return remembered(checks.account, readAccount, (c) => {
111
+ checks.account = c;
112
+ });
76
113
  }
77
114
 
78
- export function claudeAccount(): { signedIn: boolean; account: string | null } {
115
+ function readAccount(): ClaudeAccount {
79
116
  const run = spawnSync('claude', STATUS_COMMAND, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
80
117
  if (run.error !== undefined || typeof run.stdout !== 'string') return { signedIn: false, account: null };
81
118
  try {
@@ -126,6 +163,7 @@ export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now()): LoginV
126
163
  session.state = code === 0 ? 'done' : 'failed';
127
164
  if (code !== 0) session.error = `the login ended with status ${String(code)}`;
128
165
  terminal.close();
166
+ forgetClaudeChecks();
129
167
  const onboarding = code === 0 ? markOnboardingDone() : 'skipped';
130
168
  log.info({ state: session.state, onboarding }, 'claude-login: the official login finished');
131
169
  })
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { ApiError } from '@metro-labs/http/api-error';
4
4
  import { claudeDir, listClaudeProjects } from './files.js';
@@ -68,8 +68,17 @@ function projectEntries(dir: string): ClaudeSettingsFile[] {
68
68
  return out;
69
69
  }
70
70
 
71
+ const sameFile = (a: string, b: string): boolean => {
72
+ try {
73
+ return realpathSync(a) === realpathSync(b);
74
+ } catch {
75
+ return a === b;
76
+ }
77
+ };
78
+
71
79
  export function listClaudeSettings(dir = claudeDir()): ClaudeSettingsFile[] {
72
- return [entryOf(USER_ID, 'user', 'This machine', join(dir, 'settings.json')), ...projectEntries(dir)];
80
+ const user = entryOf(USER_ID, 'user', 'This machine', join(dir, 'settings.json'));
81
+ return [user, ...projectEntries(dir).filter((p) => !sameFile(p.path, user.path))];
73
82
  }
74
83
 
75
84
  function assertSettingsJson(text: string): void {
@@ -146,6 +146,18 @@ function startKeepalive(res: ServerResponse): Keepalive {
146
146
  };
147
147
  }
148
148
 
149
+ function drainedOrClosed(res: ServerResponse): Promise<void> {
150
+ return new Promise((resolve) => {
151
+ const done = (): void => {
152
+ res.off('drain', done);
153
+ res.off('close', done);
154
+ resolve();
155
+ };
156
+ res.once('drain', done);
157
+ res.once('close', done);
158
+ });
159
+ }
160
+
149
161
  async function pumpBody(
150
162
  res: ServerResponse,
151
163
  body: ReadableStream<Uint8Array>,
@@ -156,11 +168,7 @@ async function pumpBody(
156
168
  const { done, value } = await reader.read();
157
169
  if (done) return;
158
170
  touch();
159
- const ok = res.write(value);
160
- if (!ok)
161
- await new Promise((resolve) => {
162
- res.once('drain', resolve);
163
- });
171
+ if (!res.write(value)) await drainedOrClosed(res);
164
172
  }
165
173
  }
166
174
 
@@ -229,13 +237,29 @@ async function relayExchange(
229
237
  res.once('finish', () => {
230
238
  res.socket?.removeListener('close', bail);
231
239
  });
240
+ try {
241
+ await relayOnce(req, res, connectorId, deps, control.signal);
242
+ } catch (err) {
243
+ if (!control.signal.aborted) throw err;
244
+ log.debug({ connector: connectorId }, 'relay: the client left before the answer ended');
245
+ if (!res.writableEnded) res.end();
246
+ }
247
+ }
248
+
249
+ async function relayOnce(
250
+ req: IncomingMessage,
251
+ res: ServerResponse,
252
+ connectorId: string,
253
+ deps: RelayApiDeps,
254
+ signal: AbortSignal,
255
+ ): Promise<void> {
232
256
  const body = req.method === 'POST' ? await readCapped(req) : null;
233
257
  const out = await exchange(
234
258
  req,
235
259
  connectorId,
236
260
  deps,
237
261
  body,
238
- control.signal,
262
+ signal,
239
263
  );
240
264
  if (out.kind === 'missing') {
241
265
  answer(res, 404, { error: 'no such connector' });
@@ -216,19 +216,25 @@ export function localMarkSignedOut(id: string, dir = agentsDir()): void {
216
216
  replace(dir, signedOut(row));
217
217
  }
218
218
 
219
- async function freshAuth(auth: ConnectorAuth, resource: string): Promise<ConnectorAuth> {
219
+ async function freshAuth(row: LocalConnectorRow, dir: string): Promise<ConnectorAuth> {
220
+ const auth = row.config.auth;
220
221
  if (auth.kind !== 'oauth' || !oauthExpired(auth)) return auth;
221
- return refreshOAuth(auth, resource);
222
+ return refreshOnce(row, auth, dir);
223
+ }
224
+
225
+ function updateRow(dir: string, id: string, config: Partial<LocalConnectorRow['config']>): void {
226
+ const now = readLocalConnectors(dir).find((r) => r.id === id);
227
+ if (now !== undefined) replace(dir, { ...now, config: { ...now.config, ...config } });
222
228
  }
223
229
 
224
230
  export async function localVerifyConnector(subject: string, id: string, dir = agentsDir()): Promise<ConnectorCheck> {
225
231
  const row = rowOrThrow(subject, id, dir);
226
232
  try {
227
233
  const url = parseConnectorUrl(row.url);
228
- const auth = await freshAuth(row.config.auth, url.toString());
234
+ const auth = await freshAuth(row, dir);
229
235
  const verified = stamp(await verifyRemoteMcp(url, auth));
230
236
  const oauth = row.config.oauth || (await oauthCapable(url, auth));
231
- replace(dir, { ...row, config: { ...row.config, auth, verified, oauth } });
237
+ updateRow(dir, row.id, { auth, verified, oauth });
232
238
  return { id: row.id, name: row.name, ok: true, verified };
233
239
  } catch (err) {
234
240
  if (!(err instanceof ConnectorVerifyError)) throw err;
@@ -239,9 +245,7 @@ export async function localVerifyConnector(subject: string, id: string, dir = ag
239
245
  export async function localConnectorTools(subject: string, id: string, dir = agentsDir()): Promise<RemoteTool[]> {
240
246
  const row = rowOrThrow(subject, id, dir);
241
247
  const url = parseConnectorUrl(row.url);
242
- const auth = await freshAuth(row.config.auth, url.toString());
243
- if (auth !== row.config.auth) replace(dir, { ...row, config: { ...row.config, auth } });
244
- return listRemoteTools(url, auth);
248
+ return listRemoteTools(url, await freshAuth(row, dir));
245
249
  }
246
250
 
247
251
  export async function localRenameConnector(subject: string, id: string, raw: string, dir = agentsDir()): Promise<Connector> {
@@ -264,7 +268,7 @@ function refreshOnce(row: LocalConnectorRow, auth: OAuthAuth, dir: string): Prom
264
268
  if (running !== undefined) return running;
265
269
  const job = refreshOAuth(auth, parseConnectorUrl(row.url).toString())
266
270
  .then((fresh) => {
267
- replace(dir, { ...row, config: { ...row.config, auth: fresh } });
271
+ updateRow(dir, row.id, { auth: fresh });
268
272
  return fresh;
269
273
  })
270
274
  .finally(() => {
@@ -172,7 +172,7 @@ function writeEvent(res: ServerResponse, message: EventStreamMessage, scanner: U
172
172
  res.write(`event: error\ndata: ${JSON.stringify(body)}\n\n`);
173
173
  }
174
174
 
175
- async function relayStream(upstream: Response, res: ServerResponse, watch: Watch): Promise<void> {
175
+ async function relayStream(upstream: Response, res: ServerResponse, watch: Watch, key: string): Promise<void> {
176
176
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
177
177
  const body = upstream.body;
178
178
  if (body === null) {
@@ -180,7 +180,7 @@ async function relayStream(upstream: Response, res: ServerResponse, watch: Watch
180
180
  return;
181
181
  }
182
182
  const ping = setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS);
183
- const scanner = new UsageScanner('bedrock');
183
+ const scanner = new UsageScanner(key);
184
184
  try {
185
185
  const decoder = new EventStreamDecoder();
186
186
  const reader = body.getReader();
@@ -220,11 +220,11 @@ export async function bedrockMessages(
220
220
  return;
221
221
  }
222
222
  if (rewritten.stream) {
223
- await relayStream(upstream, res, up.watch);
223
+ await relayStream(upstream, res, up.watch, up.settings.id);
224
224
  return;
225
225
  }
226
226
  const text = await upstream.text();
227
- const scanner = new UsageScanner('bedrock');
227
+ const scanner = new UsageScanner(up.settings.id);
228
228
  scanner.feed(text);
229
229
  scanner.done();
230
230
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -23,12 +23,22 @@ export interface CodexDeps {
23
23
  save: (id: string, tokens: CodexTokens) => void;
24
24
  }
25
25
 
26
- export interface CodexState {
26
+ interface Slot {
27
27
  refreshing: Promise<CodexTokens> | null;
28
28
  latest: CodexTokens | null;
29
29
  }
30
30
 
31
- export const freshCodexState = (): CodexState => ({ refreshing: null, latest: null });
31
+ export type CodexState = Map<string, Slot>;
32
+
33
+ export const sharedCodexState: CodexState = new Map();
34
+
35
+ function slotFor(state: CodexState, id: string): Slot {
36
+ const found = state.get(id);
37
+ if (found !== undefined) return found;
38
+ const made: Slot = { refreshing: null, latest: null };
39
+ state.set(id, made);
40
+ return made;
41
+ }
32
42
 
33
43
  const OS_NAMES: Record<string, string> = { darwin: 'Mac OS', linux: 'Linux', win32: 'Windows' };
34
44
 
@@ -58,22 +68,23 @@ function headersFor(tokens: CodexTokens, sessionId: string): Record<string, stri
58
68
  const newerThan = (a: CodexTokens, b: CodexTokens): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
59
69
 
60
70
  function refreshed(id: string, tokens: CodexTokens, deps: CodexDeps, state: CodexState): Promise<CodexTokens> {
61
- const latest = state.latest;
71
+ const slot = slotFor(state, id);
72
+ const latest = slot.latest;
62
73
  if (latest !== null && newerThan(latest, tokens) && !tokensStale(latest)) return Promise.resolve(latest);
63
- if (state.refreshing !== null) return state.refreshing;
74
+ if (slot.refreshing !== null) return slot.refreshing;
64
75
  const run = refreshTokens(tokens, deps.issuer, deps.fetchImpl)
65
76
  .then((fresh) => {
66
77
  deps.save(id, fresh);
67
- state.latest = fresh;
78
+ slot.latest = fresh;
68
79
  return fresh;
69
80
  })
70
81
  .catch((err: unknown) => {
71
82
  throw new GatewayError(403, 'permission_error', `Codex sign-in expired (${errMsg(err)}): connect again on the Model page`);
72
83
  })
73
84
  .finally(() => {
74
- state.refreshing = null;
85
+ slot.refreshing = null;
75
86
  });
76
- state.refreshing = run;
87
+ slot.refreshing = run;
77
88
  return run;
78
89
  }
79
90
 
@@ -12,8 +12,8 @@ import {
12
12
  import { addBeta, anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
13
13
  import { BINDING_BETA, cappedEffort, effortToApply, plannedEffort, withBlockBinding, withEffort, withThinkingFor } from './effort.js';
14
14
  import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type Connection, type ModelConfig, type Route } from './model-config.js';
15
- import { codexCount, codexMessages, freshCodexState } from './codex.js';
16
- import { freshGeminiState, geminiCount, geminiMessages } from './gemini.js';
15
+ import { codexCount, codexMessages, sharedCodexState } from './codex.js';
16
+ import { geminiCount, geminiMessages, sharedGeminiState } from './gemini.js';
17
17
  import type { GeminiDeps } from './gemini.js';
18
18
  import type { CodexDeps } from './codex.js';
19
19
  import type { GeminiTokens } from './gemini-auth.js';
@@ -41,16 +41,14 @@ export interface GatewayDeps {
41
41
  }
42
42
 
43
43
  const learned: Adaptations = freshAdaptations();
44
- const codexState = freshCodexState();
45
- const geminiState = freshGeminiState();
46
44
 
47
45
  export function resetGatewayState(): void {
48
46
  forgetServed();
49
47
  forgetUsage();
50
48
  learned.fields.clear();
51
49
  learned.dropBetas = false;
52
- Object.assign(codexState, freshCodexState());
53
- Object.assign(geminiState, freshGeminiState());
50
+ sharedCodexState.clear();
51
+ sharedGeminiState.clear();
54
52
  }
55
53
 
56
54
  const saveCodexTokens = (id: string, tokens: CodexTokens): void => {
@@ -197,11 +195,11 @@ async function toSubscription(req: IncomingMessage, res: ServerResponse, path: s
197
195
  const conn = route.connection;
198
196
  if (conn.provider === 'gemini') {
199
197
  if (path === COUNT) geminiCount(res, body);
200
- else await geminiMessages(req, res, body, route.model, conn, { save: saveGeminiTokens, ...deps.gemini }, geminiState, watchUpstream(res));
198
+ else await geminiMessages(req, res, body, route.model, conn, { save: saveGeminiTokens, ...deps.gemini }, sharedGeminiState, watchUpstream(res));
201
199
  return;
202
200
  }
203
201
  if (path === COUNT) codexCount(res, body);
204
- else await codexMessages(req, res, body, route.model, conn, { save: saveCodexTokens, ...deps.codex }, codexState, watchUpstream(res));
202
+ else await codexMessages(req, res, body, route.model, conn, { save: saveCodexTokens, ...deps.codex }, sharedCodexState, watchUpstream(res));
205
203
  }
206
204
 
207
205
  function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<string, unknown> {
@@ -39,6 +39,7 @@ export class GeminiStreamTranslator {
39
39
  private toolCalls = 0;
40
40
  private usage: Usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
41
41
  private stop = 'end_turn';
42
+ private finishedBy: string | null = null;
42
43
  private refusal: string | null = null;
43
44
  private readonly model: string;
44
45
  private readonly restore: (name: string) => string;
@@ -115,6 +116,7 @@ export class GeminiStreamTranslator {
115
116
  const parts = Array.isArray(content.parts) ? content.parts.filter(isRecord) : [];
116
117
  const out = parts.map((part) => this.part(part)).join('');
117
118
  const finish = str(candidate.finishReason);
119
+ if (finish !== '') this.finishedBy = finish;
118
120
  const stop = STOP_OF[finish];
119
121
  if (stop !== undefined) this.stop = stop;
120
122
  else if (REFUSALS.has(finish)) this.refusal = `Gemini stopped the answer (${finish})`;
@@ -137,8 +139,9 @@ export class GeminiStreamTranslator {
137
139
  if (this.done) return '';
138
140
  this.done = true;
139
141
  let out = this.start() + this.closeOpen();
140
- const failure = error ?? this.refusal;
141
- if (failure !== null && failure !== undefined && this.toolCalls === 0 && this.next === 0)
142
+ const unfinished = this.finishedBy === null && this.refusal === null ? 'Gemini ended the stream before completing the response' : null;
143
+ const failure = error ?? this.refusal ?? unfinished;
144
+ if (failure !== null)
142
145
  return out + frame('error', { type: 'error', error: { type: 'api_error', message: failure } });
143
146
  const stop = this.toolCalls > 0 ? 'tool_use' : this.stop;
144
147
  out += frame('message_delta', { type: 'message_delta', delta: { stop_reason: stop, stop_sequence: null }, usage: this.usage });
@@ -22,32 +22,43 @@ export interface GeminiDeps {
22
22
  save: (id: string, tokens: GeminiTokens) => void;
23
23
  }
24
24
 
25
- export interface GeminiState {
25
+ interface Slot {
26
26
  refreshing: Promise<GeminiTokens> | null;
27
27
  latest: GeminiTokens | null;
28
28
  }
29
29
 
30
- export const freshGeminiState = (): GeminiState => ({ refreshing: null, latest: null });
30
+ export type GeminiState = Map<string, Slot>;
31
+
32
+ export const sharedGeminiState: GeminiState = new Map();
33
+
34
+ function slotFor(state: GeminiState, id: string): Slot {
35
+ const found = state.get(id);
36
+ if (found !== undefined) return found;
37
+ const made: Slot = { refreshing: null, latest: null };
38
+ state.set(id, made);
39
+ return made;
40
+ }
31
41
 
32
42
  const newerThan = (a: GeminiTokens, b: GeminiTokens): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
33
43
 
34
44
  function refreshed(id: string, tokens: GeminiTokens, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
35
- const latest = state.latest;
45
+ const slot = slotFor(state, id);
46
+ const latest = slot.latest;
36
47
  if (latest !== null && newerThan(latest, tokens) && !tokensStale(latest)) return Promise.resolve(latest);
37
- if (state.refreshing !== null) return state.refreshing;
48
+ if (slot.refreshing !== null) return slot.refreshing;
38
49
  const run = refreshTokens(tokens, deps.tokenBase, deps.fetchImpl)
39
50
  .then((fresh) => {
40
51
  deps.save(id, fresh);
41
- state.latest = fresh;
52
+ slot.latest = fresh;
42
53
  return fresh;
43
54
  })
44
55
  .catch((err: unknown) => {
45
56
  throw new GatewayError(403, 'permission_error', `Google sign-in expired (${errMsg(err)}): connect again on the Model page`);
46
57
  })
47
58
  .finally(() => {
48
- state.refreshing = null;
59
+ slot.refreshing = null;
49
60
  });
50
- state.refreshing = run;
61
+ slot.refreshing = run;
51
62
  return run;
52
63
  }
53
64