@stage-labs/metro 0.1.0-beta.315 → 0.1.0-beta.317

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 (39) hide show
  1. package/package.json +1 -1
  2. package/runtime/node_modules/@metro-labs/core/package.json +2 -0
  3. package/runtime/node_modules/@metro-labs/core/src/effort.ts +21 -0
  4. package/runtime/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
  5. package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +23 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +16 -3
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +1 -1
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +4 -4
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +10 -8
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +21 -8
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +11 -1
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +22 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +1 -1
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +30 -3
  19. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +9 -3
  20. package/runtime/node_modules/@metro-labs/daemon/src/routes/pair-again.ts +23 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-interactive.ts +1 -1
  22. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +9 -2
  23. package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +6 -0
  24. package/runtime/node_modules/@metro-labs/http/src/connector-oauth.ts +13 -4
  25. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +3 -2
  26. package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +2 -0
  27. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +35 -40
  28. package/runtime/node_modules/@metro-labs/whatsapp/src/connection.ts +139 -0
  29. package/runtime/node_modules/@metro-labs/whatsapp/src/login.ts +36 -3
  30. package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +24 -3
  31. package/runtime/node_modules/@metro-labs/whatsapp/src/names.ts +46 -15
  32. package/runtime/node_modules/@metro-labs/whatsapp/src/profile.ts +158 -0
  33. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +2 -0
  34. package/runtime/runtime.json +1 -1
  35. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +2 -0
  36. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/effort.ts +21 -0
  37. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
  38. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
  39. package/runtime/sdk-runner/runner.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.315",
3
+ "version": "0.1.0-beta.317",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,12 +19,14 @@
19
19
  "./trains/protocol": "./src/protocol.ts",
20
20
  "./ids": "./src/ids.ts",
21
21
  "./claude-token": "./src/claude-token.ts",
22
+ "./effort": "./src/effort.ts",
22
23
  "./tickets": "./src/tickets.ts",
23
24
  "./station-names": "./src/station-names.ts",
24
25
  "./str": "./src/str.ts",
25
26
  "./lines": "./src/lines.ts",
26
27
  "./endpoints": "./src/endpoints.ts",
27
28
  "./stations/types": "./src/stations/types.ts",
29
+ "./stations/account-status": "./src/stations/account-status.ts",
28
30
  "./stations/channel-directory": "./src/stations/channel-directory.ts",
29
31
  "./stations/channel-pages": "./src/stations/channel-pages.ts",
30
32
  "./stations/account-files": "./src/stations/account-files.ts",
@@ -0,0 +1,21 @@
1
+ import { isRecord } from './is-record.js';
2
+
3
+ export const EFFORT_LEVELS = ['low', 'medium', 'high', 'max', 'asked'] as const;
4
+
5
+ export type EffortLevel = (typeof EFFORT_LEVELS)[number];
6
+
7
+ export interface RouteEffort {
8
+ main?: EffortLevel;
9
+ helpers?: EffortLevel;
10
+ }
11
+
12
+ export const EFFORT_DEFAULTS: Required<RouteEffort> = { main: 'low', helpers: 'max' };
13
+
14
+ export const isEffortLevel = (value: unknown): value is EffortLevel => typeof value === 'string' && (EFFORT_LEVELS as readonly string[]).includes(value);
15
+
16
+ export function readEffort(raw: unknown): RouteEffort {
17
+ if (!isRecord(raw)) return {};
18
+ return { ...(isEffortLevel(raw.main) ? { main: raw.main } : {}), ...(isEffortLevel(raw.helpers) ? { helpers: raw.helpers } : {}) };
19
+ }
20
+
21
+ export const effortSet = (effort: RouteEffort): boolean => effort.main !== undefined || effort.helpers !== undefined;
@@ -0,0 +1,26 @@
1
+ import { isRecord } from '../is-record.js';
2
+
3
+ export const ACCOUNT_STATES = ['connected', 'connecting', 'logged_out', 'disconnected'] as const;
4
+
5
+ export type AccountState = (typeof ACCOUNT_STATES)[number];
6
+
7
+ export interface AccountStatus {
8
+ state: AccountState;
9
+ reason?: string;
10
+ since: number;
11
+ }
12
+
13
+ const REASON_MAX = 300;
14
+
15
+ export function accountStatus(state: AccountState, reason?: string, since = Date.now()): AccountStatus {
16
+ const text = reason?.trim().slice(0, REASON_MAX) ?? '';
17
+ return text === '' ? { state, since } : { state, reason: text, since };
18
+ }
19
+
20
+ export function accountStatusOf(raw: unknown): AccountStatus | null {
21
+ if (!isRecord(raw)) return null;
22
+ const state = ACCOUNT_STATES.find((s) => s === raw.state);
23
+ const since = raw.since;
24
+ if (state === undefined || typeof since !== 'number' || !Number.isFinite(since)) return null;
25
+ return accountStatus(state, typeof raw.reason === 'string' ? raw.reason : undefined, since);
26
+ }
@@ -114,6 +114,8 @@ export interface Station {
114
114
  readFilters?: ReadonlySet<ReadFilter>;
115
115
  approvals?: boolean;
116
116
  sendsFrames?: boolean;
117
+ reportsStatus?: boolean;
118
+ pairAgain?: { identity: string };
117
119
  attachmentMode: AttachmentMode;
118
120
  forget?: (accountId: string) => void;
119
121
  forgetExcept?: (keptAccountIds: readonly string[]) => void;
@@ -13,6 +13,7 @@ export type AccountRoute =
13
13
  | { kind: 'enabled'; station: StationName; accountId: string }
14
14
  | { kind: 'policy'; station: StationName; accountId: string }
15
15
  | { kind: 'profiles'; station: StationName; accountId: string }
16
+ | { kind: 'repair'; station: StationName; accountId: string }
16
17
  | { kind: 'name'; station: StationName; accountId: string };
17
18
 
18
19
  const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
@@ -26,10 +27,11 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
26
27
  enabled: ['PUT'],
27
28
  policy: ['PUT'],
28
29
  profiles: ['GET'],
30
+ repair: ['POST'],
29
31
  name: ['GET', 'POST'],
30
32
  };
31
33
 
32
- const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles'] as const;
34
+ const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles', 'repair'] as const;
33
35
 
34
36
  type SubRoute = (typeof SUB_ROUTES)[number];
35
37
 
@@ -37,6 +37,7 @@ interface AttachSessionApi {
37
37
  owner: AttachOwner,
38
38
  station: InteractiveStation,
39
39
  input: Record<string, unknown>,
40
+ repair?: string,
40
41
  ) => Promise<AttachView>;
41
42
  view: (owner: AttachOwner, attachId: string) => AttachView;
42
43
  submit: (
@@ -315,16 +316,36 @@ async function handleStep(
315
316
  sendJson(req, res, 200, view);
316
317
  }
317
318
 
319
+ async function handleRepair(
320
+ req: IncomingMessage,
321
+ res: ServerResponse,
322
+ deps: AccountApiDeps,
323
+ owner: AttachOwner,
324
+ target: { station: StationName; accountId: string },
325
+ ): Promise<void> {
326
+ const { station, accountId } = target;
327
+ if (!isInteractiveStation(station) || stationByName(station)?.pairAgain === undefined)
328
+ throw new ApiError(`${station} cannot pair again`, 400);
329
+ const view = await deps.attachSessions.start(owner, station, asInput(await readJsonBody(req)), accountId);
330
+ sendJson(req, res, 201, view);
331
+ }
332
+
333
+ type AttachRoute = Extract<AccountRoute, { kind: 'start' | 'session' | 'step' | 'repair' }>;
334
+
335
+ const isAttachRoute = (route: AccountRoute): route is AttachRoute =>
336
+ route.kind === 'start' || route.kind === 'session' || route.kind === 'step' || route.kind === 'repair';
337
+
318
338
  async function dispatchAttach(
319
339
  req: IncomingMessage,
320
340
  res: ServerResponse,
321
341
  deps: AccountApiDeps,
322
342
  agentId: string,
323
- route: Extract<AccountRoute, { kind: 'start' | 'session' | 'step' }>,
343
+ route: AttachRoute,
324
344
  session?: ApiSession,
325
345
  ): Promise<void> {
326
346
  const owner: AttachOwner = { agentId, organization: session?.subject, userId: session?.userId, sessionId: session?.sessionId, authorization: req.headers.authorization };
327
347
  if (route.kind === 'start') return handleStart(req, res, deps, agentId, owner);
348
+ if (route.kind === 'repair') return handleRepair(req, res, deps, owner, route);
328
349
  if (route.kind === 'session') return handleSession(req, res, deps, owner, route.attachId);
329
350
  return handleStep(req, res, deps, owner, route.attachId);
330
351
  }
@@ -337,7 +358,7 @@ async function dispatchRoute(
337
358
  route: AccountRoute,
338
359
  session?: ApiSession,
339
360
  ): Promise<void> {
340
- if (route.kind === 'start' || route.kind === 'session' || route.kind === 'step') return dispatchAttach(req, res, deps, agentId, route, session);
361
+ if (isAttachRoute(route)) return dispatchAttach(req, res, deps, agentId, route, session);
341
362
  if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, agentId, route);
342
363
  if (route.kind === 'enabled') return handleEnabled(req, res, deps, agentId, route);
343
364
  if (route.kind === 'policy') return handlePolicy(req, res, deps, agentId, route);
@@ -13,6 +13,7 @@ import {
13
13
  type AccountRoute,
14
14
  } from './account-routes.js';
15
15
  import type { AgentSummary } from './admin.js';
16
+ import { pairAgainStations, statusStations } from '../stations/registry.js';
16
17
 
17
18
  const PREFIX = '/api/agents';
18
19
 
@@ -70,6 +71,8 @@ async function handleList(
70
71
  capabilities: deps.capabilities(),
71
72
  features: deps.features ?? [],
72
73
  attachable: deps.attachable ?? ATTACHABLE,
74
+ reportsStatus: statusStations(),
75
+ pairAgain: pairAgainStations(),
73
76
  ...(deps.toolGroups === undefined ? {} : { tools: deps.toolGroups() }),
74
77
  };
75
78
  if (!wantsAccounts(req)) {
@@ -210,6 +210,23 @@ export function localRenewGmail(agentId: string, accountId: string, previous: st
210
210
  return { agentId, station: 'gmail', accountId };
211
211
  }
212
212
 
213
+ const sameIdentity = (a: unknown, b: unknown): boolean =>
214
+ typeof a === 'string' && typeof b === 'string' && a.replace(/[\s()+-]/g, '').toLowerCase() === b.replace(/[\s()+-]/g, '').toLowerCase();
215
+
216
+ const shownIdentity = (value: unknown): string => (typeof value === 'string' && value !== '' ? value : 'unknown');
217
+
218
+ export function localPairAgain(agentId: string, station: StationName, accountId: string, identity: string, config: Record<string, unknown>, dir = agentsDir()): AccountRef {
219
+ const stored = agentOrThrow(agentId, dir);
220
+ const account = stored.file.stations.find((s) => s.station === station && s.id === accountId);
221
+ if (account === undefined) throw new AgentAdminError('This channel is gone. Nothing was changed.', 404);
222
+ const before = account.config[identity];
223
+ const after = config[identity];
224
+ if (!sameIdentity(before, after)) throw new AgentAdminError(`This channel is ${shownIdentity(before)}, not ${shownIdentity(after)}. Nothing was changed.`, 400);
225
+ account.config = { ...account.config, ...config };
226
+ save(stored);
227
+ return { agentId, station, accountId };
228
+ }
229
+
213
230
  export function readLocalAgentFile(agentId: string, dir = agentsDir()): AgentFile {
214
231
  const found = storedAgents(dir).find((s) => s.file.id === agentId);
215
232
  if (found === undefined) throw missing();
@@ -1,9 +1,12 @@
1
+ import { readEffort, type RouteEffort } from '@metro-labs/core/effort';
1
2
  import { isRecord } from '@metro-labs/core/is-record';
2
- import { isProvider, type Account, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
3
+ import { isProvider, type Account, type Chain, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
3
4
 
4
5
  export interface DeliveredRoute {
5
6
  primary: Fallback | null;
6
7
  fallbacks: Fallback[];
8
+ helpers: Chain | null;
9
+ effort: RouteEffort;
7
10
  }
8
11
 
9
12
  export interface Delivered {
@@ -24,10 +27,20 @@ function refOf(raw: unknown): Fallback | null {
24
27
  return ref.connection === '' ? null : ref;
25
28
  }
26
29
 
30
+ function fallbacksOf(raw: Record<string, unknown>): Fallback[] {
31
+ const fallbacks = Array.isArray(raw.fallbacks) ? raw.fallbacks.flatMap((f: unknown) => refOf(f) ?? []) : [];
32
+ return fallbacks.filter((f) => f.model !== '').slice(0, MAX_FALLBACKS);
33
+ }
34
+
35
+ function helpersOf(raw: unknown): Chain | null {
36
+ if (!isRecord(raw)) return null;
37
+ const primary = refOf(raw.primary);
38
+ return primary === null ? null : { primary, fallbacks: fallbacksOf(raw) };
39
+ }
40
+
27
41
  function routeOf(raw: unknown): DeliveredRoute | null {
28
42
  if (!isRecord(raw)) return null;
29
- const fallbacks = Array.isArray(raw.fallbacks) ? raw.fallbacks.flatMap((f: unknown) => refOf(f) ?? []) : [];
30
- return { primary: refOf(raw.primary), fallbacks: fallbacks.filter((f) => f.model !== '').slice(0, MAX_FALLBACKS) };
43
+ return { primary: refOf(raw.primary), fallbacks: fallbacksOf(raw), helpers: helpersOf(raw.helpers), effort: readEffort(raw.effort) };
31
44
  }
32
45
 
33
46
  function accountOf(raw: unknown): Account {
@@ -6,7 +6,7 @@ import { deliveredSetup, type OrgModels } from './org-models.js';
6
6
  export function setupOf(delivered: Delivered | null): ModelSetup {
7
7
  const route = delivered?.route ?? null;
8
8
  if (delivered === null || route === null) return { ...emptySetup(), source: delivered?.source ?? null };
9
- return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source };
9
+ return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source, helpers: route.helpers, effort: route.effort };
10
10
  }
11
11
 
12
12
  export const effectiveSetup = (delivered: Delivered | null = deliveredSetup()): ModelSetup => setupOf(delivered);
@@ -1,8 +1,7 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
+ import { EFFORT_DEFAULTS, type RouteEffort } from '@metro-labs/core/effort';
2
3
  import { isRecord } from '@metro-labs/core/is-record';
3
4
 
4
- const MAIN_EFFORT = 'low';
5
- const SUBAGENT_EFFORT = 'max';
6
5
  export const BINDING_BETA = 'thinking-binding-controls-2026-08-01';
7
6
 
8
7
  const SUBAGENT_HEADER = 'x-claude-code-agent-id';
@@ -42,12 +41,13 @@ const messagesOf = (body: Body): unknown[] => (Array.isArray(body.messages) ? bo
42
41
 
43
42
  const askedEfforts = (body: Body): string[] => [body, ...messagesOf(body).map(record)].map(effortOf).filter((effort) => effort !== '');
44
43
 
45
- export function plannedEffort(req: IncomingMessage, body: Body): string | null {
44
+ export function plannedEffort(req: IncomingMessage, body: Body, setting: RouteEffort = {}): string | null {
46
45
  const asked = askedEfforts(body);
47
46
  if (asked.length === 0) return null;
48
47
  const kind = requestKind(req, body);
49
48
  if (kind === 'other') return null;
50
- const wanted = kind === 'subagent' ? SUBAGENT_EFFORT : MAIN_EFFORT;
49
+ const wanted = kind === 'subagent' ? (setting.helpers ?? EFFORT_DEFAULTS.helpers) : (setting.main ?? EFFORT_DEFAULTS.main);
50
+ if (wanted === 'asked') return null;
51
51
  return asked.every((effort) => effort === wanted) ? null : wanted;
52
52
  }
53
53
 
@@ -18,14 +18,16 @@ interface Cooling {
18
18
  reason: string;
19
19
  }
20
20
 
21
+ export type Lane = 'main' | 'helpers';
22
+
21
23
  const cooling = new Map<string, Cooling>();
22
- let lastChoice = '';
24
+ const lastChoice = new Map<Lane, string>();
23
25
 
24
26
  const keyOf = (route: Route): string => `${route.connection.id}\n${route.model}`;
25
27
 
26
28
  export function forgetFallbackState(): void {
27
29
  cooling.clear();
28
- lastChoice = '';
30
+ lastChoice.clear();
29
31
  }
30
32
 
31
33
  function scopeOf(label: string): string | null {
@@ -96,18 +98,18 @@ export function noteRefused(route: Route, status: number, headers: Headers, next
96
98
  log.warn({ from: routeLabel(route), to: next === undefined ? null : routeLabel(next), status }, why);
97
99
  }
98
100
 
99
- export function noteChoice(primary: Route, chosen: Route, now = Date.now()): void {
101
+ export function noteChoice(primary: Route, chosen: Route, lane: Lane, now = Date.now()): void {
100
102
  const key = keyOf(chosen);
101
- if (key === lastChoice) return;
102
- const first = lastChoice === '';
103
- lastChoice = key;
103
+ const last = lastChoice.get(lane);
104
+ if (key === last) return;
105
+ lastChoice.set(lane, key);
104
106
  if (key === keyOf(primary)) {
105
- if (!first) log.info({ model: routeLabel(chosen), connection: chosen.connection.label }, 'gateway: back on the first model of the fallback list');
107
+ if (last !== undefined) log.info({ lane, model: routeLabel(chosen), connection: chosen.connection.label }, 'gateway: back on the first model of the fallback list');
106
108
  return;
107
109
  }
108
110
  const hold = holdOf(primary, now);
109
111
  log.info(
110
- { from: routeLabel(primary), to: routeLabel(chosen), connection: chosen.connection.label, reason: hold?.reason ?? null, until: hold?.until ?? null },
112
+ { lane, from: routeLabel(primary), to: routeLabel(chosen), connection: chosen.connection.label, reason: hold?.reason ?? null, until: hold?.until ?? null },
111
113
  'gateway: the first model is over its limit, so requests go to a fallback model',
112
114
  );
113
115
  }
@@ -26,7 +26,7 @@ import {
26
26
  type Watch,
27
27
  type Refusal,
28
28
  } from './forward.js';
29
- import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
29
+ import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry, type Lane } from './fallback.js';
30
30
  import {
31
31
  BINDING_BETA,
32
32
  cappedEffort,
@@ -34,12 +34,13 @@ import {
34
34
  forgetLearnedThinking,
35
35
  learnAlwaysThinks,
36
36
  plannedEffort,
37
+ requestKind,
37
38
  thinkingOff,
38
39
  withBlockBinding,
39
40
  withEffort,
40
41
  withThinkingFor,
41
42
  } from './effort.js';
42
- import { notReady, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
43
+ import { helperSetup, notReady, pinnedRoute, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
43
44
  import { codexMessages, type CodexDeps } from './codex.js';
44
45
  import { countTokens } from './subscription.js';
45
46
  import { OPENROUTER_BASE } from './openrouter.js';
@@ -241,11 +242,23 @@ async function toCodex(req: IncomingMessage, res: ServerResponse, path: string,
241
242
  else await codexMessages(req, res, body, route.model, route.connection, { refresh: refreshCodex, ...deps.codex }, watchUpstream(res));
242
243
  }
243
244
 
244
- function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<string, unknown> {
245
- const effort = plannedEffort(req, sent);
245
+ function shapedFor(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup): Record<string, unknown> {
246
+ const effort = plannedEffort(req, sent, setup.effort);
246
247
  return effort === null ? sent : withEffort(sent, effort);
247
248
  }
248
249
 
250
+ interface Chosen {
251
+ lane: Lane;
252
+ setup: ModelSetup;
253
+ primary: Route;
254
+ }
255
+
256
+ function chosenChain(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup, requested: string): Chosen {
257
+ const helpers = requestKind(req, sent) === 'subagent' ? helperSetup(setup) : null;
258
+ if (helpers === null) return { lane: 'main', setup, primary: resolveRoute(requested, setup) ?? noRoute() };
259
+ return { lane: 'helpers', setup: helpers, primary: pinnedRoute(requested, helpers) ?? noRoute() };
260
+ }
261
+
249
262
  const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<string, unknown>): Buffer => (sent === parsed ? raw : Buffer.from(JSON.stringify(sent)));
250
263
 
251
264
  interface Request {
@@ -299,12 +312,12 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
299
312
  const parsed = parseJson(raw);
300
313
  const sent = fitToolSearch(req, parsed);
301
314
  const requested = requestedModel(sent);
302
- const primary = resolveRoute(requested, setup) ?? noRoute();
303
- const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), setup };
304
- const routes = routesToTry(chainOf(primary, setup, requested));
315
+ const { lane, setup: chain, primary } = chosenChain(req, sent, setup, requested);
316
+ const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent, setup), setup };
317
+ const routes = routesToTry(chainOf(primary, chain, requested));
305
318
  for (const [at, route] of routes.entries()) {
306
319
  const next = routes[at + 1];
307
- if (path === MESSAGES) noteChoice(primary, route);
320
+ if (path === MESSAGES) noteChoice(primary, route, lane);
308
321
  if (next === undefined) {
309
322
  await attempt(req, res, ask, route, deps);
310
323
  return;
@@ -1,9 +1,10 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { EFFORT_DEFAULTS } from '@metro-labs/core/effort';
2
3
  import { ApiError } from '@metro-labs/http/api-error';
3
4
  import { apiFailure, apiSession, cors, sendJson } from '@metro-labs/http/api-http';
4
5
  import { effectiveSetup } from './effective.js';
5
6
  import { chainStatus } from './fallback.js';
6
- import { notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
7
+ import { helperSetup, notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
7
8
  import { orgModels, type SyncStatus } from './org-models.js';
8
9
  import { lastServed } from './served.js';
9
10
  import { refreshUsage, type UsageDeps } from './usage-refresh.js';
@@ -41,6 +42,13 @@ function publicConnection(c: Connection): Record<string, unknown> {
41
42
 
42
43
  const setupOf = (deps: ModelApiDeps): ModelSetup => (deps.setup ?? (() => effectiveSetup()))();
43
44
 
45
+ function helpersBody(setup: ModelSetup): Record<string, unknown> | null {
46
+ const helpers = helperSetup(setup);
47
+ if (helpers === null) return null;
48
+ const primary = primaryRoute(helpers);
49
+ return { route: primary?.connection.id ?? '', model: primary?.model ?? '', fallbacks: helpers.fallbacks, chain: chainStatus(helpers) };
50
+ }
51
+
44
52
  export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
45
53
  const setup = setupOf(deps);
46
54
  const primary = primaryRoute(setup);
@@ -55,6 +63,8 @@ export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
55
63
  connections: setup.connections.map(publicConnection),
56
64
  fallbacks: setup.fallbacks,
57
65
  chain: chainStatus(setup),
66
+ helpers: helpersBody(setup),
67
+ effort: { ...EFFORT_DEFAULTS, ...setup.effort },
58
68
  lastServed: lastServed(),
59
69
  usage: usageSeen(),
60
70
  };
@@ -1,3 +1,5 @@
1
+ import type { RouteEffort } from '@metro-labs/core/effort';
2
+
1
3
  export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'routeway'] as const;
2
4
  export type Provider = (typeof PROVIDERS)[number];
3
5
  export type CodexMethod = 'chatgpt' | 'code';
@@ -36,11 +38,18 @@ export interface Fallback {
36
38
  model: string;
37
39
  }
38
40
 
41
+ export interface Chain {
42
+ primary: Fallback;
43
+ fallbacks: Fallback[];
44
+ }
45
+
39
46
  export interface ModelSetup {
40
47
  primary: Fallback | null;
41
48
  fallbacks: Fallback[];
42
49
  connections: Connection[];
43
50
  source: RouteSource | null;
51
+ helpers?: Chain | null;
52
+ effort?: RouteEffort;
44
53
  }
45
54
 
46
55
  export interface Route {
@@ -119,6 +128,19 @@ export function resolveRoute(requested: string, setup: ModelSetup): Route | null
119
128
  return { connection: primary.connection, model: DEFAULTS[primary.connection.provider](bareModel(requested), primary.model) };
120
129
  }
121
130
 
131
+ export function pinnedRoute(requested: string, setup: ModelSetup): Route | null {
132
+ const primary = primaryRoute(setup);
133
+ if (primary === null) return null;
134
+ const bare = bareModel(requested);
135
+ const passes = primary.connection.provider === 'anthropic' && (primary.model === '' || isSmallModel(bare));
136
+ return passes ? { connection: primary.connection, model: bare } : primary;
137
+ }
138
+
139
+ export function helperSetup(setup: ModelSetup): ModelSetup | null {
140
+ const helpers = setup.helpers ?? null;
141
+ return helpers === null ? null : { ...setup, primary: helpers.primary, fallbacks: helpers.fallbacks, helpers: null };
142
+ }
143
+
122
144
  export const routeLabel = (route: Route): string =>
123
145
  route.connection.provider === 'anthropic' ? route.model : `${route.connection.provider}:${route.model}`;
124
146
 
@@ -42,7 +42,7 @@ export async function refreshUsage(setup: ModelSetup, deps: UsageDeps = {}, now
42
42
  export function watchFallbackUsage(setup: () => ModelSetup, deps: UsageDeps = {}, every = CREDITS_TTL_MS): void {
43
43
  const run = (): void => {
44
44
  const current = setup();
45
- if (current.fallbacks.length === 0) return;
45
+ if (current.fallbacks.length === 0 && (current.helpers ?? null) === null) return;
46
46
  refreshUsage(current, deps).catch((err: unknown) => {
47
47
  log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
48
48
  });
@@ -6,6 +6,7 @@ import {
6
6
  } from '../stations/registry.js';
7
7
  import { listEndpoints } from '../stations/webhook-endpoints.js';
8
8
  import { hookUrl, threemaCallbackUrl } from '../stations/attach.js';
9
+ import { accountStatusOf } from '@metro-labs/core/stations/account-status';
9
10
  import { accountEnabled, agentIdForAccount, allowlistForAccount, approversForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
10
11
 
11
12
  const accountId = (acc: unknown): string | undefined => {
@@ -85,8 +86,33 @@ function withCallbackUrl(acc: unknown): unknown {
85
86
  return { ...rest, callback: threemaCallbackUrl(callbackId, callbackToken) };
86
87
  }
87
88
 
88
- const decorate = (station: string, rows: unknown[]): unknown[] =>
89
- station === 'threema' ? rows.map(withCallbackUrl) : rows;
89
+ function withStatus(reports: boolean, acc: unknown): unknown {
90
+ const rec = asRecord(acc);
91
+ if (rec === undefined || !('status' in rec)) return acc;
92
+ const { status, ...rest } = rec;
93
+ const live = reports ? accountStatusOf(status) : null;
94
+ return live === null ? rest : { ...rest, status: live };
95
+ }
96
+
97
+ function decorate(station: string, rows: unknown[]): unknown[] {
98
+ const reports = stationByName(station)?.reportsStatus === true;
99
+ const live = rows.map((row) => withStatus(reports, row));
100
+ return station === 'threema' ? live.map(withCallbackUrl) : live;
101
+ }
102
+
103
+ const STATUS_WAIT_MS = 5_000;
104
+
105
+ function answerIn<T>(call: Promise<T>, ms: number): Promise<T> {
106
+ let timer: ReturnType<typeof setTimeout> | undefined;
107
+ const late = new Promise<never>((_resolve, reject) => {
108
+ timer = setTimeout(() => {
109
+ reject(new Error(`no answer within ${String(ms)} ms`));
110
+ }, ms);
111
+ });
112
+ return Promise.race([call, late]).finally(() => {
113
+ clearTimeout(timer);
114
+ });
115
+ }
90
116
 
91
117
  export interface ScopedAccounts {
92
118
  accounts: Record<string, unknown[]>;
@@ -99,7 +125,8 @@ async function liveAccounts(
99
125
  if (!hasTrain(station))
100
126
  return { rows: inCoreAccounts(station), reachable: true };
101
127
  try {
102
- const resp = await forwardTrainCall(station, 'accounts', {});
128
+ const call = forwardTrainCall(station, 'accounts', {});
129
+ const resp = await (stationByName(station)?.reportsStatus === true ? answerIn(call, STATUS_WAIT_MS) : call);
103
130
  const list = (resp.result as { accounts?: unknown[] } | undefined)?.accounts;
104
131
  return { rows: decorate(station, Array.isArray(list) ? list : []), reachable: true };
105
132
  } catch {
@@ -4,6 +4,7 @@ import { AttachSessions } from '../stations/attach-session.js';
4
4
  import { startInteractiveAttach } from '../stations/attach-interactive.js';
5
5
  import { assertAttachOwner, finishGmailUpgrade, startGmailAttach } from './gmail-attach.js';
6
6
  import { detachWithGmailRevocation } from './gmail-detach.js';
7
+ import { finishPairAgain, pairAgainInput } from './pair-again.js';
7
8
  import { recentSenders } from '../agents/senders.js';
8
9
  import { forwardTrainCall } from '../stations/train-call.js';
9
10
  import type { AgentApiDeps } from '../agents/api.js';
@@ -44,11 +45,16 @@ function attachSessions(deps: LocalModeDeps): AttachSessions {
44
45
  assertAttachOwner(owner);
45
46
  return Promise.resolve();
46
47
  },
47
- start: (station, input, hooks, owner) => station === 'gmail' ? startGmailAttach(station, input, hooks, owner) : startInteractiveAttach(station, input, hooks),
48
- complete: async (owner, station, config, active) => {
48
+ start: (station, input, hooks, owner, repair) => {
49
+ if (repair !== undefined) return startInteractiveAttach(station, pairAgainInput(owner, station, repair, input), hooks);
50
+ return station === 'gmail' ? startGmailAttach(station, input, hooks, owner) : startInteractiveAttach(station, input, hooks);
51
+ },
52
+ complete: async (owner, station, config, active, repair) => {
49
53
  assertAttachOwner(owner);
50
54
  if (!active()) throw new ApiError('This sign-in was cancelled or expired.', 409);
51
- const ref = (station === 'gmail' ? finishGmailUpgrade(owner, config) : null) ?? await localAttachAccount(owner.agentId, station, config);
55
+ const ref = repair !== undefined
56
+ ? finishPairAgain(owner, station, repair, config)
57
+ : (station === 'gmail' ? finishGmailUpgrade(owner, config) : null) ?? await localAttachAccount(owner.agentId, station, config);
52
58
  const activated = await deps.syncStations(station).then(
53
59
  () => true,
54
60
  (err: unknown) => {
@@ -0,0 +1,23 @@
1
+ import { ApiError } from '@metro-labs/http/api-error';
2
+ import type { StationName } from '@metro-labs/core/station-names';
3
+ import { localPairAgain, readLocalAgentFile } from '../agents/file-admin.js';
4
+ import type { AccountRef } from '../agents/account-attach.js';
5
+ import { stationByName } from '../stations/registry.js';
6
+ import type { AttachOwner } from '../stations/attach-session.js';
7
+
8
+ function identityOf(station: string): string {
9
+ const identity = stationByName(station)?.pairAgain?.identity;
10
+ if (identity === undefined) throw new ApiError(`${station} cannot pair again`, 400);
11
+ return identity;
12
+ }
13
+
14
+ export function pairAgainInput(owner: AttachOwner, station: string, accountId: string, input: Record<string, unknown>): Record<string, unknown> {
15
+ const identity = identityOf(station);
16
+ const account = readLocalAgentFile(owner.agentId).stations.find((s) => s.station === station && s.id === accountId);
17
+ if (account === undefined) throw new ApiError('No such channel on this agent.', 404);
18
+ return { ...input, expect: account.config[identity] };
19
+ }
20
+
21
+ export function finishPairAgain(owner: AttachOwner, station: StationName, accountId: string, config: Record<string, unknown>): AccountRef {
22
+ return localPairAgain(owner.agentId, station, accountId, identityOf(station), config);
23
+ }
@@ -97,7 +97,7 @@ async function startWhatsapp(
97
97
  },
98
98
  onPaired: hooks.done,
99
99
  onFailed: hooks.fail,
100
- });
100
+ }, normalizePhone(input.expect));
101
101
  await login.start();
102
102
  } catch (err) {
103
103
  await login?.cancel();