@stage-labs/metro 0.1.0-beta.316 → 0.1.0-beta.318

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 (32) hide show
  1. package/package.json +1 -1
  2. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  3. package/runtime/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
  4. package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
  6. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +23 -2
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +30 -3
  10. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +13 -3
  11. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +9 -3
  12. package/runtime/node_modules/@metro-labs/daemon/src/routes/pair-again.ts +23 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-interactive.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +9 -2
  15. package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +6 -0
  16. package/runtime/node_modules/@metro-labs/http/src/connector-oauth.ts +13 -4
  17. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +14 -1
  18. package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +24 -13
  19. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +3 -2
  20. package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +2 -0
  21. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +35 -40
  22. package/runtime/node_modules/@metro-labs/whatsapp/src/connection.ts +139 -0
  23. package/runtime/node_modules/@metro-labs/whatsapp/src/login.ts +36 -3
  24. package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +24 -3
  25. package/runtime/node_modules/@metro-labs/whatsapp/src/names.ts +46 -15
  26. package/runtime/node_modules/@metro-labs/whatsapp/src/profile.ts +158 -0
  27. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +2 -0
  28. package/runtime/runtime.json +1 -1
  29. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
  30. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
  31. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
  32. 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.316",
3
+ "version": "0.1.0-beta.318",
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": {
@@ -26,6 +26,7 @@
26
26
  "./lines": "./src/lines.ts",
27
27
  "./endpoints": "./src/endpoints.ts",
28
28
  "./stations/types": "./src/stations/types.ts",
29
+ "./stations/account-status": "./src/stations/account-status.ts",
29
30
  "./stations/channel-directory": "./src/stations/channel-directory.ts",
30
31
  "./stations/channel-pages": "./src/stations/channel-pages.ts",
31
32
  "./stations/account-files": "./src/stations/account-files.ts",
@@ -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();
@@ -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 {
@@ -46,10 +46,16 @@ const noticeText = (tool: string, id: string, behavior: Behavior): string =>
46
46
 
47
47
  const requestedAt = (at: number, now = Date.now()): number => (Number.isFinite(at) && at > 0 ? Math.min(at, now) : now);
48
48
 
49
+ const askedBy = (approval: McpApproval): string => {
50
+ if (approval.origin !== 'key') return '';
51
+ return approval.keyLabel === undefined ? ' (from an access key)' : ` (from an access key: ${approval.keyLabel})`;
52
+ };
53
+
49
54
  interface Held {
50
55
  id: string;
51
56
  tool: string;
52
57
  line: string | undefined;
58
+ tellAgent: boolean;
53
59
  answer: (decision: McpDecision) => Promise<AnswerOutcome>;
54
60
  publish: (event: MetroEvent) => void;
55
61
  }
@@ -60,6 +66,10 @@ async function deliver(held: Held, behavior: Behavior): Promise<void> {
60
66
  log.info({ id: held.id, outcome }, 'mcp-server: the answer was not taken, so the agent is not told');
61
67
  return;
62
68
  }
69
+ if (!held.tellAgent) {
70
+ log.info({ id: held.id }, 'mcp-server: the call came with an access key, not from this agent\'s session, so the agent is not told');
71
+ return;
72
+ }
63
73
  const line = noticeLineFor(held.line);
64
74
  if (line === undefined) {
65
75
  log.warn({ id: held.id }, 'mcp-server: no chat is known to tell the agent the answer');
@@ -91,15 +101,15 @@ export function holdServerApproval(approval: McpApproval, answer: Held['answer']
91
101
  const tool = `${approval.connector}.${approval.tool}`;
92
102
  const announced = announcedLine(approval.id);
93
103
  const line = announced ?? promptLineFor(approval.id);
94
- const held: Held = { id: approval.id, tool, line, answer, publish };
104
+ const held: Held = { id: approval.id, tool, line, tellAgent: approval.origin !== 'key', answer, publish };
95
105
  holdPrompt(
96
106
  { requestId: approval.id, tool, description: approval.connector, preview: approval.preview, line, at: requestedAt(approval.requestedAt), origin: 'mcp-server' },
97
107
  owner,
98
108
  sender(held),
99
109
  line === undefined ? undefined : async (text) => metroSend(line, text),
100
110
  );
101
- log.info({ id: approval.id, tool, inChat: line !== undefined, announced: announced !== undefined }, 'mcp-server: an approval is waiting for the owner');
102
- if (line !== undefined && announced === undefined) announce(approval.id, line, serverPromptBody({ requestId: approval.id, tool, preview: approval.preview }));
111
+ log.info({ id: approval.id, tool, origin: approval.origin ?? null, inChat: line !== undefined, announced: announced !== undefined }, 'mcp-server: an approval is waiting for the owner');
112
+ if (line !== undefined && announced === undefined) announce(approval.id, line, serverPromptBody({ requestId: approval.id, tool: `${tool}${askedBy(approval)}`, preview: approval.preview }));
103
113
  return true;
104
114
  }
105
115
 
@@ -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();
@@ -19,6 +19,7 @@ export type StartAttach = (
19
19
  input: Record<string, unknown>,
20
20
  hooks: DriverHooks,
21
21
  owner: AttachOwner,
22
+ repair?: string,
22
23
  ) => Promise<StartedAttach>;
23
24
 
24
25
  export const ATTACH_ID_RE = /^as_[A-Za-z0-9_-]{22}$/;
@@ -60,6 +61,7 @@ type CompleteAttach = (
60
61
  station: StationName,
61
62
  config: Record<string, unknown>,
62
63
  active: () => boolean,
64
+ repair?: string,
63
65
  ) => Promise<{ accountId: string; activated: boolean }>;
64
66
 
65
67
  type AuthorizeAttach = (owner: AttachOwner) => Promise<void>;
@@ -72,6 +74,7 @@ export interface AttachSessionDeps {
72
74
 
73
75
  interface Session {
74
76
  owner: AttachOwner;
77
+ repair?: string;
75
78
  driver: AttachDriver | null;
76
79
  view: AttachView;
77
80
  finishing?: boolean;
@@ -177,6 +180,7 @@ export class AttachSessions {
177
180
  session.committing = true;
178
181
  return true;
179
182
  },
183
+ session.repair,
180
184
  );
181
185
  this.settle(session, {
182
186
  status: 'done',
@@ -187,12 +191,12 @@ export class AttachSessions {
187
191
  });
188
192
  log.info(
189
193
  { agentId: session.owner.agentId, station, account: landed.accountId },
190
- 'attach-session: signed in and attached a station account',
194
+ session.repair === undefined ? 'attach-session: signed in and attached a station account' : 'attach-session: paired a station account again',
191
195
  );
192
196
  } catch (err) {
193
197
  const reason =
194
198
  err instanceof ApiError ? err.message : 'could not store the account';
195
- log.warn({ station, err: errMsg(err) }, 'attach-session: store failed');
199
+ log.warn({ station, ...(err instanceof ApiError ? { status: err.status } : { err: errMsg(err).replace(/\d{6,}/g, '<number>') }) }, 'attach-session: store failed');
196
200
  this.settle(session, { status: 'failed', prompt: '', error: reason });
197
201
  }
198
202
  }
@@ -230,6 +234,7 @@ export class AttachSessions {
230
234
  owner: AttachOwner,
231
235
  station: InteractiveStation,
232
236
  input: Record<string, unknown>,
237
+ repair?: string,
233
238
  ): Promise<AttachView> {
234
239
  await this.deps.authorize(owner);
235
240
  this.admit(owner);
@@ -237,6 +242,7 @@ export class AttachSessions {
237
242
  const attachId = newAttachId();
238
243
  const session: Session = {
239
244
  owner,
245
+ ...(repair === undefined ? {} : { repair }),
240
246
  driver: null,
241
247
  view: blankView(attachId, station),
242
248
  };
@@ -248,6 +254,7 @@ export class AttachSessions {
248
254
  input,
249
255
  this.hooksFor(attachId, station),
250
256
  owner,
257
+ repair,
251
258
  );
252
259
  if (this.sessions.get(attachId) !== session || session.view.expiresAt <= Date.now()) {
253
260
  await started.driver.cancel();
@@ -41,6 +41,12 @@ export const accountStationCapabilities = (): Record<string, string[]> => {
41
41
  return out;
42
42
  };
43
43
 
44
+ export const statusStations = (): string[] =>
45
+ STATIONS.filter((s) => s.reportsStatus === true).map((s) => s.name);
46
+
47
+ export const pairAgainStations = (): string[] =>
48
+ STATIONS.filter((s) => s.pairAgain !== undefined).map((s) => s.name);
49
+
44
50
  export function forgetOrphans(known: readonly { station: string; id: string }[]): void {
45
51
  for (const s of STATIONS) {
46
52
  if (s.forgetExcept === undefined) continue;
@@ -11,6 +11,8 @@ const CLIENT_NAME = 'Metro';
11
11
 
12
12
  export class OAuthInvalidGrant extends ConnectorVerifyError {}
13
13
 
14
+ const SIGN_IN_AGAIN = new Set(['invalid_grant', 'invalid_client', 'unauthorized_client']);
15
+
14
16
  export interface OAuthClient {
15
17
  clientId: string;
16
18
  clientSecret?: string;
@@ -143,10 +145,17 @@ async function postToken(
143
145
  } catch (err) {
144
146
  throw refused(`Metro could not reach that server to finish signing in: ${whyUnreachable(err)}`);
145
147
  }
146
- const body: unknown = await res.json().catch(() => null);
147
- if (form.grant_type === 'refresh_token' && res.status === 400 && isRecord(body) && body.error === 'invalid_grant') throw new OAuthInvalidGrant('That server requires a new sign-in.', 400);
148
- if (!res.ok || !isRecord(body))
149
- throw refused('That server refused to complete the sign-in.');
148
+ return tokenAnswer(form.grant_type === 'refresh_token', res.status, res.ok, await res.json().catch(() => null));
149
+ }
150
+
151
+ const errorCode = (body: unknown): string | null => (isRecord(body) && typeof body.error === 'string' && /^[a-z_]{1,64}$/.test(body.error) ? body.error : null);
152
+
153
+ const grantGone = (refreshing: boolean, status: number, code: string | null): boolean => refreshing && (status === 400 || status === 401) && code !== null && SIGN_IN_AGAIN.has(code);
154
+
155
+ function tokenAnswer(refreshing: boolean, status: number, ok: boolean, body: unknown): OAuthTokens {
156
+ const code = errorCode(body);
157
+ if (grantGone(refreshing, status, code)) throw new OAuthInvalidGrant('That server requires a new sign-in.', 400);
158
+ if (!ok || !isRecord(body)) throw refused(`That server refused to complete the sign-in (${String(status)}${code === null ? '' : ` ${code}`}).`);
150
159
  return tokensFrom(body);
151
160
  }
152
161
 
@@ -27,12 +27,18 @@ export interface McpCall {
27
27
  approval?: string;
28
28
  }
29
29
 
30
+ type McpApprovalOrigin = 'box' | 'key';
31
+
32
+ const KEY_LABEL_MAX = 64;
33
+
30
34
  export interface McpApproval {
31
35
  id: string;
32
36
  connector: string;
33
37
  tool: string;
34
38
  preview: string;
35
39
  requestedAt: number;
40
+ origin?: McpApprovalOrigin;
41
+ keyLabel?: string;
36
42
  }
37
43
 
38
44
  export type McpDecision = 'allow' | 'deny';
@@ -70,9 +76,16 @@ export function mcpTokenHashOf(raw: unknown): string {
70
76
  return raw.tokenHash;
71
77
  }
72
78
 
79
+ function originOf(raw: Record<string, unknown>): Pick<McpApproval, 'origin' | 'keyLabel'> {
80
+ if (raw.origin === 'box') return { origin: 'box' };
81
+ if (raw.origin !== 'key') return {};
82
+ const label = typeof raw.keyLabel === 'string' ? raw.keyLabel.replace(/\p{Cc}/gu, '').trim() : '';
83
+ return label === '' || label.length > KEY_LABEL_MAX ? { origin: 'key' } : { origin: 'key', keyLabel: label };
84
+ }
85
+
73
86
  function approvalOf(raw: unknown): McpApproval | null {
74
87
  if (!isRecord(raw) || typeof raw.id !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.id) || typeof raw.connector !== 'string' || typeof raw.tool !== 'string' || typeof raw.preview !== 'string' || typeof raw.requestedAt !== 'number') return null;
75
- return { id: raw.id, connector: raw.connector, tool: raw.tool, preview: raw.preview, requestedAt: raw.requestedAt };
88
+ return { id: raw.id, connector: raw.connector, tool: raw.tool, preview: raw.preview, requestedAt: raw.requestedAt, ...originOf(raw) };
76
89
  }
77
90
 
78
91
  export function mcpApprovalsOf(raw: unknown): McpApproval[] {
@@ -61,19 +61,30 @@ export const toolSearchDescription = (keys: string[] | null): string =>
61
61
  'Results are full definitions (name, description and the JSON schema of the arguments) in a <functions> block. Each name is <connector>.<tool>: pass it exactly as "tool" to tool_call. Tools the owner blocked are never returned.',
62
62
  ].join('\n');
63
63
 
64
- export const TOOL_CALL_DESCRIPTION = [
65
- 'Run connector tools that tool_search found. Always find a tool with tool_search first: its exact name and argument schema come from there.',
66
- `Shape: ${TOOL_CALL_EXAMPLE}`,
67
- `Up to ${String(MCP_CALLS_MAX)} independent calls run in parallel in one tool_call and are answered in order. Each result starts with "[n] <connector>.<tool>", then the tool's own content, or one line saying why it did not run:`,
68
- '- not_found: no such connector or tool here. Run tool_search, then use a name it returned.',
69
- '- needs_approval: the owner was asked in chat and on metro.box. Tell the person what is waiting, wait for the channel message "Approved: <tool> (<id>)", then call again with the same tool and arguments plus "approval": "<id>". An approval works once.',
70
- '- denied or blocked: the owner refused or blocked this call. Do not retry it.',
71
- '- signin: the connector must be signed in again. Tell the owner to do it on the organization\'s Connectors page on metro.box.',
72
- '- unavailable: the connector or Metro could not be reached. Try again later.',
73
- '- vendor_error: the connector answered with its own error, shown as it came.',
74
- ].join('\n');
64
+ export type ApprovalPlace = 'session' | 'page' | 'owner';
75
65
 
76
- export function mcpServerTools(keys: string[] | null): McpServerTool[] {
66
+ const NEEDS_APPROVAL: Record<ApprovalPlace, string> = {
67
+ session: '- needs_approval: the owner was asked in chat and on metro.box. Tell the person what is waiting, wait for the channel message "Approved: <tool> (<id>)", then call again with the same tool and arguments plus "approval": "<id>". An approval works once.',
68
+ page: '- needs_approval: the owner must approve it on this agent\'s page on metro.box. Tell the person what is waiting and ask them to approve it there, then call again with the same tool and arguments plus "approval": "<id>". An approval works once, within 10 minutes of the answer.',
69
+ owner: '- needs_approval: the agent\'s owner was asked in chat and on its page on metro.box. Tell the person what is waiting; once it is approved, call again with the same tool and arguments plus "approval": "<id>". An approval works once, within 10 minutes of the answer.',
70
+ };
71
+
72
+ const toolCallDescription = (place: ApprovalPlace): string =>
73
+ [
74
+ 'Run connector tools that tool_search found. Always find a tool with tool_search first: its exact name and argument schema come from there.',
75
+ `Shape: ${TOOL_CALL_EXAMPLE}`,
76
+ `Up to ${String(MCP_CALLS_MAX)} independent calls run in parallel in one tool_call and are answered in order. Each result starts with "[n] <connector>.<tool>", then the tool's own content, or one line saying why it did not run:`,
77
+ '- not_found: no such connector or tool here. Run tool_search, then use a name it returned.',
78
+ NEEDS_APPROVAL[place],
79
+ '- denied or blocked: the owner refused or blocked this call. Do not retry it.',
80
+ '- signin: the connector must be signed in again. Tell the owner to do it on the organization\'s Connectors page on metro.box.',
81
+ '- unavailable: the connector or Metro could not be reached. Try again later.',
82
+ '- vendor_error: the connector answered with its own error, shown as it came.',
83
+ ].join('\n');
84
+
85
+ export const TOOL_CALL_DESCRIPTION = toolCallDescription('session');
86
+
87
+ export function mcpServerTools(keys: string[] | null, place: ApprovalPlace = 'session'): McpServerTool[] {
77
88
  return [
78
89
  {
79
90
  name: TOOL_SEARCH,
@@ -85,7 +96,7 @@ export function mcpServerTools(keys: string[] | null): McpServerTool[] {
85
96
  {
86
97
  name: TOOL_CALL,
87
98
  title: 'Call connector tools',
88
- description: TOOL_CALL_DESCRIPTION,
99
+ description: toolCallDescription(place),
89
100
  inputSchema: callSchema,
90
101
  annotations: { title: 'Call connector tools', readOnlyHint: false, destructiveHint: true, openWorldHint: true },
91
102
  },
@@ -196,12 +196,13 @@ function makeDelete(clientFor: ClientFor): StationHandler {
196
196
  function makeAccounts(clientFor: ClientFor): StationHandler {
197
197
  return (id) => {
198
198
  const list = [...accounts.values()].map((a) => {
199
- const phone = clientFor(a.id).self();
199
+ const client = clientFor(a.id);
200
+ const phone = client.self();
200
201
  return {
201
202
  id: a.id,
202
203
  handle: phone === null ? null : `+${phone}`,
203
204
  url: phone === null ? null : `https://wa.me/${phone}`,
204
- connected: phone !== null,
205
+ status: client.status(),
205
206
  };
206
207
  });
207
208
  respond(id, { result: { accounts: list } });
@@ -4,6 +4,7 @@ import type { MemberList } from '@metro-labs/core/stations/types';
4
4
  import type { ChannelList } from '@metro-labs/core/stations/channel-directory';
5
5
  import type { ProfileChange } from '@metro-labs/core/stations/profile';
6
6
  import type { SenderProfile } from '@metro-labs/core/stations/sender-profile';
7
+ import type { AccountStatus } from '@metro-labs/core/stations/account-status';
7
8
  import type { WhatsAppAccount } from './types.js';
8
9
  import type { InboundMessage, ReactionInput } from './format.js';
9
10
  import type { SenderFound } from './resolve.js';
@@ -25,6 +26,7 @@ export interface WAMedia {
25
26
  export interface WAClient {
26
27
  account: WhatsAppAccount;
27
28
  self(): string | null;
29
+ status(): AccountStatus;
28
30
  start(handlers: InboundHandlers): Promise<void>;
29
31
  read(jid: string, options?: HistoryOptions): Promise<HistoryPage>;
30
32
  listMembers(jid: string, limit?: number): Promise<MemberList>;