@stage-labs/metro 0.1.0-beta.85 → 0.1.0-beta.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.85",
3
+ "version": "0.1.0-beta.87",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,5 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
+ import { normalizeAllowlist } from './allowlist.js';
4
+ import type { RecentSender } from './senders.js';
3
5
  import { ApiError } from '@metro-labs/http/api-error';
4
6
  import {
5
7
  apiFailure,
@@ -63,13 +65,24 @@ export interface AccountApiDeps {
63
65
  accountId: string,
64
66
  ) => Promise<AccountRef>;
65
67
  syncStations: (station: StationName) => Promise<void>;
68
+ reloadAgents: () => Promise<void>;
69
+ setAllowlist: (
70
+ subject: string,
71
+ agentId: string,
72
+ station: StationName,
73
+ accountId: string,
74
+ allowlist: string[],
75
+ ) => Promise<string[]>;
76
+ recentSenders: (station: StationName, accountId: string) => RecentSender[];
66
77
  }
67
78
 
68
79
  export type AccountRoute =
69
80
  | { kind: 'start' }
70
81
  | { kind: 'session'; attachId: string }
71
82
  | { kind: 'step'; attachId: string }
72
- | { kind: 'account'; station: StationName; accountId: string };
83
+ | { kind: 'account'; station: StationName; accountId: string }
84
+ | { kind: 'allowlist'; station: StationName; accountId: string }
85
+ | { kind: 'senders'; station: StationName; accountId: string };
73
86
 
74
87
  export const ATTACHABLE: string[] = [
75
88
  ...ATTACHABLE_STATIONS,
@@ -81,6 +94,8 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
81
94
  session: ['GET', 'DELETE'],
82
95
  step: ['POST'],
83
96
  account: ['DELETE'],
97
+ allowlist: ['PUT'],
98
+ senders: ['GET'],
84
99
  };
85
100
 
86
101
  function twoSegmentRoute(head: string, tail: string): AccountRoute | null {
@@ -100,10 +115,17 @@ export function accountRoute(rest: string[]): AccountRoute | null {
100
115
  : ATTACH_ID_RE.test(head)
101
116
  ? { kind: 'session', attachId: head }
102
117
  : null;
118
+ if (rest.length === 3 && tail !== undefined) return accountSubRoute(head, tail, rest[2]);
103
119
  if (rest.length !== 2 || tail === undefined) return null;
104
120
  return twoSegmentRoute(head, tail);
105
121
  }
106
122
 
123
+ function accountSubRoute(head: string, tail: string, sub: string | undefined): AccountRoute | null {
124
+ if (!isStationName(head) || (sub !== 'allowlist' && sub !== 'senders')) return null;
125
+ const accountId = parseAccountId(tail);
126
+ return accountId === null ? null : { kind: sub, station: head, accountId };
127
+ }
128
+
107
129
  export function accountRouteAllows(
108
130
  route: AccountRoute,
109
131
  method: string | undefined,
@@ -242,6 +264,36 @@ async function handleDetach(
242
264
  });
243
265
  }
244
266
 
267
+ async function applied(deps: AccountApiDeps): Promise<boolean> {
268
+ try {
269
+ await deps.reloadAgents();
270
+ return true;
271
+ } catch (err) {
272
+ log.warn({ err: errMsg(err) }, 'account-api: allowlist reload failed, the change lands at the next boot');
273
+ return false;
274
+ }
275
+ }
276
+
277
+ async function handleAllowlist(
278
+ req: IncomingMessage,
279
+ res: ServerResponse,
280
+ deps: AccountApiDeps,
281
+ session: ApiSession,
282
+ agentId: string,
283
+ target: { station: StationName; accountId: string },
284
+ ): Promise<void> {
285
+ const wanted = normalizeAllowlist(bodyField(await readJsonBody(req), 'allowlist'));
286
+ const allowlist = await deps.setAllowlist(session.subject, agentId, target.station, target.accountId, wanted);
287
+ log.info({ agentId, station: target.station, account: target.accountId, senders: allowlist.length }, 'account-api: allowlist set');
288
+ sendJson(req, res, 200, {
289
+ agentId,
290
+ station: target.station,
291
+ accountId: target.accountId,
292
+ allowlist,
293
+ activated: await applied(deps),
294
+ });
295
+ }
296
+
245
297
  async function handleSession(
246
298
  req: IncomingMessage,
247
299
  res: ServerResponse,
@@ -292,6 +344,11 @@ async function dispatchRoute(
292
344
  );
293
345
  if (route.kind === 'step')
294
346
  return handleStep(req, res, deps, ownerOf(session, agentId), route.attachId);
347
+ if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, session, agentId, route);
348
+ if (route.kind === 'senders') {
349
+ sendJson(req, res, 200, { senders: deps.recentSenders(route.station, route.accountId) });
350
+ return;
351
+ }
295
352
  return handleDetach(req, res, deps, session, agentId, route);
296
353
  }
297
354
 
@@ -0,0 +1,37 @@
1
+ import { ApiError } from '@metro-labs/http/api-error';
2
+
3
+ export const EVERYONE = '*';
4
+ const MAX_ENTRIES = 500;
5
+ const MAX_LENGTH = 200;
6
+
7
+ function hasControlChar(value: string): boolean {
8
+ for (const ch of value) {
9
+ const code = ch.codePointAt(0) ?? 0;
10
+ if (code < 0x20 || code === 0x7f) return true;
11
+ }
12
+ return false;
13
+ }
14
+
15
+ function senderId(entry: unknown): string {
16
+ if (typeof entry !== 'string') throw new ApiError('allowlist must be a list of sender ids', 400);
17
+ const value = entry.trim();
18
+ if (value.length > MAX_LENGTH || hasControlChar(value))
19
+ throw new ApiError(`a sender id is at most ${String(MAX_LENGTH)} plain characters`, 400);
20
+ return value;
21
+ }
22
+
23
+ export function normalizeAllowlist(raw: unknown): string[] {
24
+ if (!Array.isArray(raw)) throw new ApiError('allowlist must be a list of sender ids', 400);
25
+ if (raw.length > MAX_ENTRIES) throw new ApiError(`allowlist holds at most ${String(MAX_ENTRIES)} senders`, 400);
26
+ const seen = new Set<string>();
27
+ const out: string[] = [];
28
+ for (const entry of raw) {
29
+ const value = senderId(entry);
30
+ if (value === EVERYONE) return [EVERYONE];
31
+ const key = value.toLowerCase();
32
+ if (key === '' || seen.has(key)) continue;
33
+ seen.add(key);
34
+ out.push(value);
35
+ }
36
+ return out.length === 0 ? [EVERYONE] : out;
37
+ }
@@ -310,6 +310,22 @@ export async function localAttachAccount(
310
310
  return Promise.resolve({ agentId, station, accountId });
311
311
  }
312
312
 
313
+ export async function localSetAllowlist(
314
+ subject: string,
315
+ agentId: string,
316
+ station: StationName,
317
+ accountId: string,
318
+ allowlist: string[],
319
+ dir = agentsDir(),
320
+ ): Promise<string[]> {
321
+ const stored = ownedOrThrow(subject, agentId, dir);
322
+ const account = stored.file.stations.find((a) => a.station === station && a.id === accountId);
323
+ if (account === undefined) throw new AgentAdminError('no such account on this agent', 404);
324
+ account.allowlist = allowlist;
325
+ save(stored);
326
+ return Promise.resolve(allowlist);
327
+ }
328
+
313
329
  export async function localDetachAccount(
314
330
  subject: string,
315
331
  agentId: string,
@@ -65,6 +65,10 @@ export function stationAgentIds(station: string): string[] {
65
65
  .map(([, id]) => id);
66
66
  }
67
67
 
68
+ export function allowlistForAccount(station: string, accountId: string): string[] | undefined {
69
+ return allowlistMap[mapKey(station, accountId)];
70
+ }
71
+
68
72
  export function allowlistForLine(line: string): string[] | undefined {
69
73
  const a = accountFromLine(line);
70
74
  return a ? allowlistMap[mapKey(a.station, a.accountId)] : undefined;
@@ -0,0 +1,38 @@
1
+ import { bufferedSince, type MetroEvent } from '@metro-labs/core/events';
2
+ import { accountFromLine } from './map.js';
3
+
4
+ export interface RecentSender {
5
+ id: string;
6
+ name: string;
7
+ at: string;
8
+ }
9
+
10
+ const DEFAULT_LIMIT = 20;
11
+
12
+ const SKIP = new Set(['', 'self', 'unknown']);
13
+
14
+ function sameAccount(from: string, station: string, accountId: string): boolean {
15
+ const account = accountFromLine(from);
16
+ return account?.station === station && account.accountId === accountId;
17
+ }
18
+
19
+ function senderOf(event: MetroEvent, station: string, accountId: string): { id: string; name: string } | null {
20
+ const from = String(event.from);
21
+ if (event.station !== station || !sameAccount(from, station, accountId)) return null;
22
+ const id = from.split('/').pop() ?? '';
23
+ if (SKIP.has(id)) return null;
24
+ return { id, name: event.fromDisplayName ?? event.fromName ?? '' };
25
+ }
26
+
27
+ export function recentSenders(station: string, accountId: string, limit = DEFAULT_LIMIT): RecentSender[] {
28
+ const seen = new Set<string>();
29
+ const out: RecentSender[] = [];
30
+ for (const { event } of bufferedSince(0).reverse()) {
31
+ const sender = senderOf(event, station, accountId);
32
+ if (sender === null || seen.has(sender.id.toLowerCase())) continue;
33
+ seen.add(sender.id.toLowerCase());
34
+ out.push({ ...sender, at: event.ts });
35
+ if (out.length >= limit) break;
36
+ }
37
+ return out;
38
+ }
@@ -102,6 +102,9 @@ async function syncStations(station: StationName): Promise<void> {
102
102
  function sessionApis(): SessionApis {
103
103
  return localSessionApis({
104
104
  syncStations,
105
+ reloadAgents: async () => {
106
+ await reloadFrom(fileSource);
107
+ },
105
108
  restart: () => {
106
109
  exitCode = RESTART_EXIT;
107
110
  onShutdown();
@@ -4,6 +4,14 @@ import { apiFailure, apiSession, cors, readJsonBody, sendJson } from '@metro-lab
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
6
  import { listClaudeSettings, SETTINGS_MAX, writeClaudeSettings } from './settings.js';
7
+ import {
8
+ createClaudeSkill,
9
+ deleteClaudeSkill,
10
+ listClaudeSkills,
11
+ readClaudeSkill,
12
+ skillHomes,
13
+ writeClaudeSkill,
14
+ } from './skills.js';
7
15
  import {
8
16
  answerClaudeLogin,
9
17
  claudeAccount,
@@ -54,6 +62,10 @@ const COLLECTIONS: Record<string, Handler> = {
54
62
  sessions: (query, dir) => ({ sessions: listClaudeSessions(projectOf(query), dir) }),
55
63
  memory: (query, dir) => listMemory(projectOf(query), dir),
56
64
  settings: (_query, dir) => ({ files: listClaudeSettings(dir) }),
65
+ skills: (_query, dir) => ({
66
+ skills: listClaudeSkills(dir),
67
+ places: skillHomes(dir).map((home) => ({ id: home.prefix, scope: home.scope, where: home.where })),
68
+ }),
57
69
  };
58
70
 
59
71
  const ITEMS: Record<string, Handler> = {
@@ -62,17 +74,30 @@ const ITEMS: Record<string, Handler> = {
62
74
  return readTranscript(projectOf(query), id, offset, limit, dir);
63
75
  },
64
76
  memory: (query, dir, name) => ({ name, content: readMemoryFile(projectOf(query), name, dir) }),
77
+ skills: (_query, dir, id) => readClaudeSkill(decodeURIComponent(id), dir),
65
78
  };
66
79
 
67
80
  const parts = (path: string): string[] => path.slice(PREFIX.length + 1).split('/').filter(Boolean);
68
81
 
82
+ const seenIn = (body: Record<string, unknown>): string | null | undefined =>
83
+ 'seenAt' in body ? (typeof body.seenAt === 'string' ? body.seenAt : null) : undefined;
84
+
69
85
  async function writeAnswer(req: IncomingMessage, path: string, dir: string): Promise<unknown> {
70
86
  const [head = '', item = ''] = parts(path);
71
- if (head !== 'settings' || item === '') throw new ApiError('method not allowed', 405);
87
+ if ((head !== 'settings' && head !== 'skills') || item === '') throw new ApiError('method not allowed', 405);
72
88
  const body = await readJsonBody(req, BODY_MAX);
73
89
  if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
74
- const seenAt = 'seenAt' in body ? (typeof body.seenAt === 'string' ? body.seenAt : null) : undefined;
75
- return writeClaudeSettings(item, body.text, seenAt, dir);
90
+ if (head === 'skills') return writeClaudeSkill(decodeURIComponent(item), body.text, seenIn(body), dir);
91
+ return writeClaudeSettings(item, body.text, seenIn(body), dir);
92
+ }
93
+
94
+ async function created(req: IncomingMessage, path: string, dir: string): Promise<unknown> {
95
+ const rest = parts(path);
96
+ if (rest.length !== 1 || rest[0] !== 'skills') throw new ApiError('method not allowed', 405);
97
+ const body = await readJsonBody(req, BODY_MAX);
98
+ if (!isRecord(body) || typeof body.name !== 'string') throw new ApiError('name is required', 400);
99
+ const scope = typeof body.scope === 'string' ? body.scope : undefined;
100
+ return createClaudeSkill(body.name, scope, typeof body.text === 'string' ? body.text : undefined, dir);
76
101
  }
77
102
 
78
103
  const LOGIN = 'login';
@@ -94,7 +119,9 @@ async function loginAnswer(req: IncomingMessage, id: string, deps: ClaudeApiDeps
94
119
 
95
120
  function removed(rest: string[], query: URLSearchParams, dir: string): unknown {
96
121
  const [head = '', item = ''] = rest;
97
- if (rest.length !== 2 || head !== 'sessions') throw new ApiError('method not allowed', 405);
122
+ if (rest.length !== 2) throw new ApiError('method not allowed', 405);
123
+ if (head === 'skills') return { deleted: deleteClaudeSkill(decodeURIComponent(item), dir) };
124
+ if (head !== 'sessions') throw new ApiError('method not allowed', 405);
98
125
  deleteClaudeSession(projectOf(query), item, dir);
99
126
  return { deleted: item };
100
127
  }
@@ -132,6 +159,7 @@ export function handleClaudeRequest(
132
159
  const [head = '', item = ''] = parts(path);
133
160
  if (head === LOGIN) return loginAnswer(req, item, deps);
134
161
  if (req.method === 'PUT') return writeAnswer(req, path, dir);
162
+ if (req.method === 'POST') return created(req, path, dir);
135
163
  return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
136
164
  })
137
165
  .then((body) => {
@@ -0,0 +1,154 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { ApiError } from '@metro-labs/http/api-error';
4
+ import { claudeDir, listClaudeProjects } from './files.js';
5
+
6
+ export const SKILL_MAX = 256 * 1024;
7
+ export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
8
+ const USER_SCOPE = 'user';
9
+ const FILE = 'SKILL.md';
10
+ const DEFAULT_MODE = 0o644;
11
+ const SUMMARY_MAX = 300;
12
+
13
+ export type SkillScope = 'user' | 'project';
14
+
15
+ export interface ClaudeSkill {
16
+ id: string;
17
+ name: string;
18
+ title: string;
19
+ description: string;
20
+ scope: SkillScope;
21
+ where: string;
22
+ path: string;
23
+ editable: boolean;
24
+ updatedAt: string | null;
25
+ }
26
+
27
+ const frontmatterField = (text: string, field: string): string => {
28
+ const head = text.startsWith('---') ? text.slice(3, text.indexOf('\n---', 3)) : '';
29
+ for (const line of head.split('\n')) {
30
+ const at = line.indexOf(':');
31
+ if (at > 0 && line.slice(0, at).trim() === field) return line.slice(at + 1).trim().replace(/^["']|["']$/g, '').slice(0, SUMMARY_MAX);
32
+ }
33
+ return '';
34
+ };
35
+
36
+ function entryOf(id: string, name: string, scope: SkillScope, where: string, path: string): ClaudeSkill {
37
+ const stat = statSync(path);
38
+ const editable = stat.size <= SKILL_MAX;
39
+ const text = editable ? readFileSync(path, 'utf8') : '';
40
+ return {
41
+ id,
42
+ name,
43
+ title: frontmatterField(text, 'name') || name,
44
+ description: frontmatterField(text, 'description'),
45
+ scope,
46
+ where,
47
+ path,
48
+ editable,
49
+ updatedAt: stat.mtime.toISOString(),
50
+ };
51
+ }
52
+
53
+ function skillsIn(root: string, prefix: string, scope: SkillScope, where: string): ClaudeSkill[] {
54
+ if (!existsSync(root)) return [];
55
+ const out: ClaudeSkill[] = [];
56
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
57
+ if (!entry.isDirectory() || !SKILL_NAME_RE.test(entry.name)) continue;
58
+ const path = join(root, entry.name, FILE);
59
+ if (existsSync(path)) out.push(entryOf(`${prefix}:${entry.name}`, entry.name, scope, where, path));
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export const userSkillsRoot = (dir: string): string => join(dir, 'skills');
65
+
66
+ export interface SkillHome {
67
+ prefix: string;
68
+ scope: SkillScope;
69
+ where: string;
70
+ root: string;
71
+ }
72
+
73
+ export function skillHomes(dir = claudeDir()): SkillHome[] {
74
+ const homes: SkillHome[] = [{ prefix: USER_SCOPE, scope: USER_SCOPE, where: 'This machine', root: userSkillsRoot(dir) }];
75
+ for (const project of listClaudeProjects(dir)) {
76
+ const cwd = project.cwd;
77
+ if (cwd === null || !existsSync(cwd)) continue;
78
+ homes.push({ prefix: project.id, scope: 'project', where: cwd, root: join(cwd, '.claude', 'skills') });
79
+ }
80
+ return homes;
81
+ }
82
+
83
+ export function listClaudeSkills(dir = claudeDir()): ClaudeSkill[] {
84
+ const out = skillHomes(dir).flatMap((home) => skillsIn(home.root, home.prefix, home.scope, home.where));
85
+ return out.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') || a.name.localeCompare(b.name));
86
+ }
87
+
88
+ function found(id: string, dir: string): ClaudeSkill {
89
+ const skill = listClaudeSkills(dir).find((entry) => entry.id === id);
90
+ if (skill === undefined) throw new ApiError('no such skill', 404);
91
+ return skill;
92
+ }
93
+
94
+ export function readClaudeSkill(id: string, dir = claudeDir()): ClaudeSkill & { text: string } {
95
+ const skill = found(id, dir);
96
+ return { ...skill, text: skill.editable ? readFileSync(skill.path, 'utf8') : '' };
97
+ }
98
+
99
+ function writeAtomic(path: string, text: string): void {
100
+ mkdirSync(dirname(path), { recursive: true });
101
+ const tmp = `${path}.metro-${String(process.pid)}`;
102
+ writeFileSync(tmp, text, { mode: DEFAULT_MODE });
103
+ renameSync(tmp, path);
104
+ }
105
+
106
+ function assertText(text: string): void {
107
+ if (text.length > SKILL_MAX) throw new ApiError('that is more text than a skill may hold', 413);
108
+ if (text.trim() === '') throw new ApiError('a skill needs some text', 400);
109
+ }
110
+
111
+ export function writeClaudeSkill(
112
+ id: string,
113
+ text: string,
114
+ seenAt: string | null | undefined,
115
+ dir = claudeDir(),
116
+ ): ClaudeSkill {
117
+ const skill = found(id, dir);
118
+ if (!skill.editable) throw new ApiError('that skill is too large to edit here', 409);
119
+ assertText(text);
120
+ if (seenAt !== undefined && seenAt !== skill.updatedAt)
121
+ throw new ApiError('that skill changed on disk since you opened it; reload it before saving', 409);
122
+ writeAtomic(skill.path, text);
123
+ return entryOf(skill.id, skill.name, skill.scope, skill.where, skill.path);
124
+ }
125
+
126
+ export const skillTemplate = (name: string): string =>
127
+ ['---', `name: ${name}`, 'description: what this skill does, and when Claude should reach for it', '---', '', `# ${name}`, '', 'Write the instructions here.', ''].join('\n');
128
+
129
+ export function createClaudeSkill(
130
+ name: string,
131
+ scope: string | undefined,
132
+ text: string | undefined,
133
+ dir = claudeDir(),
134
+ ): ClaudeSkill {
135
+ if (typeof name !== 'string' || !SKILL_NAME_RE.test(name))
136
+ throw new ApiError('a skill name is lowercase letters, digits and dashes, up to 64 characters', 400);
137
+ const prefix = scope === undefined || scope === '' ? USER_SCOPE : scope;
138
+ const home = skillHomes(dir).find((h) => h.prefix === prefix);
139
+ if (home === undefined) throw new ApiError('no such place to keep a skill', 404);
140
+ const path = join(home.root, name, FILE);
141
+ if (existsSync(path)) throw new ApiError('a skill by that name already lives there', 409);
142
+ const body = text === undefined || text.trim() === '' ? skillTemplate(name) : text;
143
+ assertText(body);
144
+ writeAtomic(path, body);
145
+ return entryOf(`${home.prefix}:${name}`, name, home.scope, home.where, path);
146
+ }
147
+
148
+ export function deleteClaudeSkill(id: string, dir = claudeDir()): string {
149
+ const skill = found(id, dir);
150
+ const folder = dirname(skill.path);
151
+ if (basename(folder) !== skill.name) throw new ApiError('that skill does not live in its own folder', 409);
152
+ rmSync(folder, { recursive: true, force: true });
153
+ return id;
154
+ }
@@ -5,7 +5,7 @@ import {
5
5
  } from '../stations/registry.js';
6
6
  import { listEndpoints } from '../net/tunnel.js';
7
7
  import { hookUrl } from '../stations/attach.js';
8
- import { agentIdForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
8
+ import { agentIdForAccount, allowlistForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
9
9
 
10
10
  const accountId = (acc: unknown): string | undefined => {
11
11
  const id = (acc as { id?: unknown }).id;
@@ -23,7 +23,9 @@ function withAgentId(station: string, acc: unknown): unknown {
23
23
  const id = typeof rec.id === 'string' ? rec.id : undefined;
24
24
  if (id === undefined) return acc;
25
25
  const agentId = agentIdForAccount(station, id);
26
- return agentId === undefined ? acc : { ...rec, agentId };
26
+ if (agentId === undefined) return acc;
27
+ const allowlist = allowlistForAccount(station, id);
28
+ return allowlist === undefined ? { ...rec, agentId } : { ...rec, agentId, allowlist };
27
29
  }
28
30
 
29
31
  export function attachAgentIds(
@@ -1,6 +1,7 @@
1
1
  import { ApiError } from '@metro-labs/http/api-error';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
3
  import { AttachSessions } from '../stations/attach-session.js';
4
+ import { recentSenders } from '../agents/senders.js';
4
5
  import type { AgentApiDeps } from '../agents/api.js';
5
6
  import { ATTACHABLE, type AccountApiDeps } from '../agents/accounts-api.js';
6
7
  import type { SessionApis } from './session-apis.js';
@@ -33,6 +34,7 @@ import {
33
34
  localCreateAgent,
34
35
  localDeleteAgent,
35
36
  localDetachAccount,
37
+ localSetAllowlist,
36
38
  localImportAgent,
37
39
  localListAgents,
38
40
  localOwnedAgentOrThrow,
@@ -46,6 +48,7 @@ import type { StationName } from '@metro-labs/core/station-names';
46
48
 
47
49
  export interface LocalModeDeps {
48
50
  syncStations: (station: StationName) => Promise<void>;
51
+ reloadAgents: () => Promise<void>;
49
52
  restart: () => void;
50
53
  stop: () => void;
51
54
  closeAgentSession: (id: string) => Promise<boolean>;
@@ -98,6 +101,9 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
98
101
  attachAccount: localAttachAccount,
99
102
  detachAccount: localDetachAccount,
100
103
  syncStations: deps.syncStations,
104
+ setAllowlist: localSetAllowlist,
105
+ recentSenders,
106
+ reloadAgents: deps.reloadAgents,
101
107
  };
102
108
  }
103
109
 
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.85"
2
+ "version": "0.1.0-beta.87"
3
3
  }