@stage-labs/metro 0.1.0-beta.262 → 0.1.0-beta.264

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 (35) hide show
  1. package/dist/runtime-install.js +22 -5
  2. package/package.json +1 -1
  3. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  4. package/runtime/marketplace/plugin/bin/policy.mjs +1 -1
  5. package/runtime/node_modules/@metro-labs/core/src/events.ts +45 -1
  6. package/runtime/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +2 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +3 -3
  9. package/runtime/node_modules/@metro-labs/daemon/src/monitor/run-event.ts +139 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/monitor/run-events.ts +101 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/stations/runtime-deps.ts +23 -7
  13. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +32 -1
  14. package/runtime/node_modules/@metro-labs/whatsapp/src/app-state.ts +49 -0
  15. package/runtime/node_modules/@metro-labs/whatsapp/src/client-history.ts +31 -0
  16. package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +46 -0
  17. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +75 -77
  18. package/runtime/node_modules/@metro-labs/whatsapp/src/history-events.ts +127 -0
  19. package/runtime/node_modules/@metro-labs/whatsapp/src/history-files.ts +39 -0
  20. package/runtime/node_modules/@metro-labs/whatsapp/src/history-identity.ts +58 -0
  21. package/runtime/node_modules/@metro-labs/whatsapp/src/history-message.ts +161 -0
  22. package/runtime/node_modules/@metro-labs/whatsapp/src/history-state.ts +344 -0
  23. package/runtime/node_modules/@metro-labs/whatsapp/src/history-types.ts +91 -0
  24. package/runtime/node_modules/@metro-labs/whatsapp/src/history.ts +195 -0
  25. package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +1 -1
  26. package/runtime/node_modules/@metro-labs/whatsapp/src/members.ts +94 -0
  27. package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +70 -0
  28. package/runtime/node_modules/@metro-labs/whatsapp/src/send-expiry.ts +65 -0
  29. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
  30. package/runtime/patches/5bca2c07d5852d3f58453cff0448dd85da13d3665b61e3d758905b298d432d7c.patch +31 -0
  31. package/runtime/runtime.json +1 -1
  32. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +45 -1
  33. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
  34. package/runtime/sdk-runner/runner.json +1 -1
  35. package/runtime/stations.json +3 -0
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
5
5
  import { localStations } from './local.js';
6
6
  import { STORE_ENTRY, daemonEntry, findBun, runtimeDir } from './runtime.js';
7
7
  const MANIFEST_FILE = 'stations.json';
@@ -24,6 +24,7 @@ export function readManifest(path) {
24
24
  return {
25
25
  core: ranges(raw.core),
26
26
  stations: Object.fromEntries(Object.entries(stations).map(([name, deps]) => [name, ranges(deps)])),
27
+ ...(raw.patchedDependencies === undefined ? {} : { patchedDependencies: ranges(raw.patchedDependencies) }),
27
28
  };
28
29
  }
29
30
  export function dependenciesFor(manifest, stations) {
@@ -32,7 +33,19 @@ export function dependenciesFor(manifest, stations) {
32
33
  Object.assign(out, manifest.stations[station] ?? {});
33
34
  return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
34
35
  }
35
- const packageText = (deps) => `${JSON.stringify({ name: 'metro-runtime', private: true, dependencies: deps }, null, 2)}\n`;
36
+ function packageText(deps, patches) {
37
+ const patchedDependencies = Object.fromEntries(Object.entries(deps).flatMap(([name, version]) => {
38
+ const key = `${name}@${version}`;
39
+ const path = patches[key];
40
+ return path === undefined ? [] : [[key, path]];
41
+ }));
42
+ return `${JSON.stringify({
43
+ name: 'metro-runtime',
44
+ private: true,
45
+ dependencies: deps,
46
+ ...(Object.keys(patchedDependencies).length === 0 ? {} : { patchedDependencies }),
47
+ }, null, 2)}\n`;
48
+ }
36
49
  export function readOrNull(path) {
37
50
  try {
38
51
  return readFileSync(path, 'utf8');
@@ -63,8 +76,8 @@ function syncSources(sources, store) {
63
76
  writeFileSync(join(store, 'runtime.json'), stamp);
64
77
  return true;
65
78
  }
66
- export function installDependencies(store, deps, bun, log) {
67
- const wanted = packageText(deps);
79
+ export function installDependencies(store, deps, bun, log, patches = {}) {
80
+ const wanted = packageText(deps, patches);
68
81
  const marker = join(store, 'node_modules', '.metro-installed');
69
82
  if (existsSync(marker) && readOrNull(join(store, 'package.json')) === wanted)
70
83
  return false;
@@ -92,6 +105,10 @@ export function prepareRuntime(opts = {}) {
92
105
  });
93
106
  mkdirSync(join(store, 'trains'), { recursive: true });
94
107
  syncSources(sources, store);
95
- installDependencies(store, dependenciesFor(manifest, localStations(opts.agents)), opts.bun ?? findBun(), log);
108
+ for (const path of Object.values(manifest.patchedDependencies ?? {})) {
109
+ mkdirSync(dirname(join(store, path)), { recursive: true });
110
+ copyFileSync(join(sources, path), join(store, path));
111
+ }
112
+ installDependencies(store, dependenciesFor(manifest, localStations(opts.agents)), opts.bun ?? findBun(), log, manifest.patchedDependencies);
96
113
  return { dir: store, entry: daemonEntry(store), trains: join(store, 'trains'), manifest: manifestPath };
97
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.262",
3
+ "version": "0.1.0-beta.264",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.262",
3
+ "version": "0.1.0-beta.264",
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",
@@ -36,7 +36,7 @@ function targets(snap, name, input) {
36
36
  if (Array.isArray(snap.ungated) && snap.ungated.includes(name)) return [];
37
37
  const account = text(input.account);
38
38
  const line = text(input[LINE_FIELD[name] ?? 'line']);
39
- if (line) return fromLine(line, name in LINE_FIELD ? '' : account);
39
+ if (line) return fromLine(line, name in LINE_FIELD || name === 'list_members' ? '' : account);
40
40
  const owners = isRecord(snap.owners) ? snap.owners : {};
41
41
  const station = (STATION_ARG.has(name) ? text(input.station) : text(owners[name])) || (account ? stationOfAccount(snap, account) : undefined);
42
42
  if (!station) return [];
@@ -85,7 +85,51 @@ export interface BufferedEvent {
85
85
  event: MetroEvent;
86
86
  }
87
87
 
88
- const BUS_BUFFER_MAX = 500;
88
+ export const BUS_BUFFER_MAX = 500;
89
+
90
+ export interface RunEventMetadata {
91
+ isPrivate?: boolean;
92
+ senderVerified?: boolean;
93
+ mentionsSelf?: boolean;
94
+ replyToSelf?: boolean;
95
+ emoji?: string;
96
+ removed?: boolean;
97
+ targetId?: string;
98
+ attachmentCount?: number;
99
+ attachmentTypes?: ('image' | 'video' | 'audio' | 'file')[];
100
+ attachmentStatus?: 'saved' | 'failed';
101
+ attachmentFor?: string;
102
+ }
103
+
104
+ export interface RunEvent {
105
+ id: string;
106
+ seq: number;
107
+ ts: string;
108
+ kind: StructuredEvent['type'];
109
+ direction: 'inbound' | 'outbound' | 'system';
110
+ agentId: string;
111
+ station: string;
112
+ accountId: string | null;
113
+ line: string;
114
+ lineName?: string;
115
+ from?: string;
116
+ fromName?: string;
117
+ fromDisplayName?: string;
118
+ to?: string;
119
+ text?: string;
120
+ truncated: boolean;
121
+ messageId?: string;
122
+ replyTo?: string;
123
+ metadata: RunEventMetadata;
124
+ }
125
+
126
+ export interface RunEventsPage {
127
+ events: RunEvent[];
128
+ cursor: string;
129
+ hasMore: boolean;
130
+ reset: boolean;
131
+ retention: { capacity: number; oldestAt: string | null; oldestSeq: number | null };
132
+ }
89
133
 
90
134
  const listeners = new Set<BusListener>();
91
135
  const buffer: BufferedEvent[] = [];
@@ -1,4 +1,4 @@
1
- import { errMsg } from '../log.js';
1
+ import { errMsg, logFatalSync } from '../log.js';
2
2
  import { readCalls } from '../protocol.js';
3
3
  import { TrainError } from '../train-error.js';
4
4
  import type { CallMsg } from './station-runtime.js';
@@ -18,12 +18,33 @@ export interface ClientTrain<A extends { id: string }, C> {
18
18
  loadAccounts: () => A[];
19
19
  createClient: (account: A) => C;
20
20
  startInbound: (client: C) => Promise<void>;
21
+ disconnectClient?: (client: C) => Promise<void>;
21
22
  makeHandleCall: (clientFor: (accountId: string) => C) => (msg: CallMsg) => Promise<void>;
22
23
  }
23
24
 
24
25
  export function runClientTrain<A extends { id: string }, C>(train: ClientTrain<A, C>): void {
25
26
  const clients = new Map<string, C>();
27
+ let stopping = false;
28
+ if (train.disconnectClient) {
29
+ const disconnect = train.disconnectClient;
30
+ const shutdown = (): void => {
31
+ if (stopping) return;
32
+ stopping = true;
33
+ process.stdin.pause();
34
+ Promise.allSettled([...clients.values()].map(async (client) => { await disconnect(client); })).then((results) => {
35
+ const errors = results.flatMap((result) => result.status === 'rejected' ? [errMsg(result.reason)] : []);
36
+ if (errors.length) logFatalSync({ station: train.station, errors }, 'train: shutdown failed');
37
+ process.exit(errors.length ? 1 : 0);
38
+ }).catch((err: unknown) => {
39
+ logFatalSync({ station: train.station, err: errMsg(err) }, 'train: shutdown failed');
40
+ process.exit(1);
41
+ });
42
+ };
43
+ process.on('SIGTERM', shutdown);
44
+ process.on('SIGINT', shutdown);
45
+ }
26
46
  const clientFor = (accountId: string): C => {
47
+ if (stopping) throw new TrainError('train_stopping', `${train.station} is shutting down`);
27
48
  const known = clients.get(accountId);
28
49
  if (known !== undefined) return known;
29
50
  const account = train.accounts.get(accountId);
@@ -16,6 +16,8 @@ export function setAgentMap(map: AgentMap, names: AgentNameMap): void {
16
16
  agentNames = names;
17
17
  }
18
18
 
19
+ export const knownAgentIds = (): string[] => Object.keys(agentNames);
20
+
19
21
  export function setAllowlistMap(map: AllowlistMap): void {
20
22
  allowlistMap = map;
21
23
  }
@@ -13,9 +13,9 @@ type ChannelTarget = Extract<PolicyTarget, { kind: 'channel' }>;
13
13
 
14
14
  export const UNGATED = new Set<string>(['list_accounts', 'create_upload']);
15
15
 
16
- function fromLine(line: string, override: string | undefined): ChannelTarget[] {
16
+ function fromLine(line: string, override = ''): ChannelTarget[] {
17
17
  const acct = accountFromLine(line);
18
- return acct === undefined ? [] : [{ kind: 'channel', station: acct.station, account: override ?? acct.accountId }];
18
+ return acct === undefined ? [] : [{ kind: 'channel', station: acct.station, account: override || acct.accountId }];
19
19
  }
20
20
 
21
21
  function channelTargets(name: string, a: Record<string, unknown>): ChannelTarget[] {
@@ -26,7 +26,7 @@ function channelTargets(name: string, a: Record<string, unknown>): ChannelTarget
26
26
  }
27
27
  const account = str(a.account);
28
28
  const line = str(a.line);
29
- if (line) return fromLine(line, account || undefined);
29
+ if (line) return fromLine(line, name === 'list_members' ? undefined : account);
30
30
  const station = stationForTool(name, a) ?? (account ? stationOfAccount(account) : undefined);
31
31
  if (station === undefined) return [];
32
32
  if (account) return [{ kind: 'channel', station, account }];
@@ -0,0 +1,139 @@
1
+ import { classifyEvent, userSelf, type BufferedEvent, type MetroEvent, type RunEvent, type RunEventMetadata } from '@metro-labs/core/events';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { Line } from '@metro-labs/core/lines';
4
+ import { STATIONS } from '@metro-labs/core/station-names';
5
+ import { accountFromLine, allowlistForLine, lineReceives, senderPermitted } from '../agents/map.js';
6
+ import { eventInScope } from '../agents/scope.js';
7
+
8
+ const TEXT_MAX = 8_000;
9
+ const FIELD_MAX = 512;
10
+ const LINE_MAX = 2_048;
11
+ const ACCOUNT_STATIONS = new Set<string>(STATIONS);
12
+ const KINDS = new Set(['msg', 'reply', 'react', 'edit', 'delete', 'system']);
13
+
14
+ function safeText(value: unknown, max: number): string | undefined {
15
+ if (typeof value !== 'string') return undefined;
16
+ return value.slice(0, max).replace(/https?:\/\/[^\s<>"']+|\/(?:attach|api\/uploads|api\/webhooks|api\/threema)\/[^\s<>"']*/gi, (url) => /\/(?:attach|api\/uploads|api\/webhooks|api\/threema)\//i.test(url) ? '[private link]' : url).slice(0, max);
17
+ }
18
+
19
+ function validLine(line: string): boolean {
20
+ return typeof line === 'string' && line.length <= LINE_MAX && /^metro:\/\/[a-z][a-z0-9-]*\/[^\s?#]+$/.test(line) && !line.includes('//', 8) && !line.endsWith('/');
21
+ }
22
+
23
+ const isSelf = (line: string): boolean => line === userSelf();
24
+ const isSynthetic = (payload: unknown): boolean => isRecord(payload) && (payload.contentType === 'attachmentSaved' || payload.contentType === 'attachmentFailed' || payload.contentType === 'transcript');
25
+
26
+ function sameAccount(line: string, other: string): boolean {
27
+ if (!validLine(other)) return false;
28
+ const a = accountFromLine(line);
29
+ const b = accountFromLine(other);
30
+ return a !== undefined && b !== undefined && a.station === b.station && a.accountId === b.accountId;
31
+ }
32
+
33
+ function permittedTraffic(event: MetroEvent): boolean {
34
+ if (!isSelf(event.to) && !sameAccount(event.line, event.to)) return false;
35
+ const allowlist = allowlistForLine(event.line);
36
+ if (isSelf(event.from)) return !isSynthetic(event.payload) || senderPermitted(allowlist, '');
37
+ return sameAccount(event.line, event.from) && senderPermitted(allowlist, event.from, event.senderVerified);
38
+ }
39
+
40
+ function visible(event: MetroEvent, allowed: Set<string>): boolean {
41
+ if (!validLine(event.line) || !eventInScope(allowed, event.line) || !lineReceives(event.line)) return false;
42
+ const station = Line.station(event.line);
43
+ if (station !== event.station) return false;
44
+ if (!ACCOUNT_STATIONS.has(station)) return station === 'claude' && event.event?.type === 'system' && event.isPrivate !== true;
45
+ return permittedTraffic(event);
46
+ }
47
+
48
+ function attachmentType(value: unknown): 'image' | 'video' | 'audio' | 'file' {
49
+ if (!isRecord(value)) return 'file';
50
+ const mime = typeof value.mime === 'string' ? value.mime : value.contentType;
51
+ for (const kind of ['image', 'video', 'audio'] as const) {
52
+ if (value.kind === kind || (typeof mime === 'string' && mime.startsWith(`${kind}/`))) return kind;
53
+ }
54
+ return 'file';
55
+ }
56
+
57
+ function attachmentMetadata(payload: unknown): RunEventMetadata {
58
+ if (!isRecord(payload)) return {};
59
+ const saved = payload.contentType === 'attachmentSaved';
60
+ const failed = payload.contentType === 'attachmentFailed';
61
+ if (saved || failed) return {
62
+ attachmentCount: 1,
63
+ attachmentTypes: [attachmentType(payload)],
64
+ attachmentStatus: saved ? 'saved' : 'failed',
65
+ attachmentFor: safeText(payload.attachmentFor, FIELD_MAX),
66
+ };
67
+ if (!Array.isArray(payload.attachments) || payload.attachments.length === 0) return {};
68
+ return {
69
+ attachmentCount: payload.attachments.length,
70
+ attachmentTypes: [...new Set(payload.attachments.slice(0, 100).map(attachmentType))],
71
+ };
72
+ }
73
+
74
+ function reactionMetadata(event: MetroEvent): RunEventMetadata {
75
+ if (event.event?.type !== 'react') return {};
76
+ return {
77
+ emoji: safeText(event.event.emoji, 32),
78
+ ...(isRecord(event.payload) && typeof event.payload.removed === 'boolean' ? { removed: event.payload.removed } : {}),
79
+ };
80
+ }
81
+
82
+ function metadata(event: MetroEvent, system: boolean): RunEventMetadata {
83
+ const attachments = attachmentMetadata(event.payload);
84
+ if (system) return attachments;
85
+ const structured = event.event;
86
+ return {
87
+ ...attachments,
88
+ ...(typeof event.isPrivate === 'boolean' ? { isPrivate: event.isPrivate } : {}),
89
+ ...(typeof event.senderVerified === 'boolean' ? { senderVerified: event.senderVerified } : {}),
90
+ ...(typeof event.mentionsSelf === 'boolean' ? { mentionsSelf: event.mentionsSelf } : {}),
91
+ ...(typeof event.replyToSelf === 'boolean' ? { replyToSelf: event.replyToSelf } : {}),
92
+ ...reactionMetadata(event),
93
+ ...(structured !== undefined && 'targetId' in structured ? { targetId: safeText(structured.targetId, FIELD_MAX) } : {}),
94
+ };
95
+ }
96
+
97
+ function messageFields(event: MetroEvent, system: boolean): Partial<RunEvent> & { truncated: boolean } {
98
+ if (system) return { truncated: false };
99
+ return {
100
+ lineName: safeText(event.lineName, FIELD_MAX),
101
+ from: safeText(event.from, LINE_MAX),
102
+ fromName: safeText(event.fromName, FIELD_MAX),
103
+ fromDisplayName: safeText(event.fromDisplayName, FIELD_MAX),
104
+ to: safeText(event.to, LINE_MAX),
105
+ text: safeText(event.text, TEXT_MAX),
106
+ truncated: typeof event.text === 'string' && event.text.length > TEXT_MAX,
107
+ };
108
+ }
109
+
110
+ function validIdentity(event: MetroEvent): boolean {
111
+ return typeof event.id === 'string' && event.id.length <= FIELD_MAX
112
+ && typeof event.ts === 'string' && event.ts.length <= 64 && Number.isFinite(Date.parse(event.ts));
113
+ }
114
+
115
+ const replyId = (event: MetroEvent): string | undefined => event.replyTo ?? (event.event?.type === 'reply' ? event.event.replyTo : undefined);
116
+ const accountId = (event: MetroEvent): string | null => ACCOUNT_STATIONS.has(event.station) ? accountFromLine(event.line)?.accountId ?? null : null;
117
+
118
+ export function runEvent(entry: BufferedEvent, agentId: string): RunEvent | null {
119
+ const { event, busSeq } = entry;
120
+ if (!visible(event, new Set([agentId])) || !validIdentity(event)) return null;
121
+ const structured = event.event ?? classifyEvent(event);
122
+ if (!KINDS.has(structured.type)) return null;
123
+ const system = structured.type === 'system' || isSynthetic(event.payload);
124
+ return {
125
+ ...messageFields(event, system),
126
+ id: safeText(event.id, FIELD_MAX) ?? '',
127
+ seq: busSeq,
128
+ ts: new Date(event.ts).toISOString(),
129
+ kind: system ? 'system' : structured.type,
130
+ direction: system ? 'system' : isSelf(event.from) ? 'outbound' : 'inbound',
131
+ agentId,
132
+ station: event.station,
133
+ accountId: accountId(event),
134
+ line: event.line,
135
+ messageId: safeText(event.messageId, FIELD_MAX),
136
+ replyTo: safeText(replyId(event), FIELD_MAX),
137
+ metadata: metadata(event, system),
138
+ };
139
+ }
@@ -0,0 +1,101 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { bufferedSince, BUS_BUFFER_MAX, currentBusSeq, type BufferedEvent, type RunEvent, type RunEventsPage } from '@metro-labs/core/events';
4
+ import { ID_RE } from '@metro-labs/core/ids';
5
+ import { ApiError } from '@metro-labs/http/api-error';
6
+ import { sessionRoute } from '@metro-labs/http/api-http';
7
+ import { accountEnabled, allowlistForAccount, knownAccounts, knownAgentIds } from '../agents/map.js';
8
+ import { runEvent } from './run-event.js';
9
+
10
+ const EPOCH = randomUUID();
11
+ const DEFAULT_LIMIT = 100;
12
+ const PAGE_BYTES_MAX = 1_000_000;
13
+ const CURSOR_RE = /^([a-f0-9-]{36}):([a-f0-9]{64}):(0|[1-9]\d{0,15})$/;
14
+
15
+ interface Cursor { epoch: string; policy: string; seq: number }
16
+ interface Query { agentId: string; limit: number; cursor: Cursor | null }
17
+
18
+ function cursorOf(raw: string | null): Cursor | null {
19
+ if (raw === null) return null;
20
+ const match = CURSOR_RE.exec(raw);
21
+ if (!match || !Number.isSafeInteger(Number(match[3]))) throw new ApiError('invalid run events cursor', 400);
22
+ return { epoch: match[1] ?? '', policy: match[2] ?? '', seq: Number(match[3]) };
23
+ }
24
+
25
+ function limitOf(raw: string | null): number {
26
+ if (raw === null) return DEFAULT_LIMIT;
27
+ const limit = Number(raw);
28
+ if (!/^[1-9]\d{0,2}$/.test(raw) || limit > BUS_BUFFER_MAX) throw new ApiError('limit must be between 1 and 500', 400);
29
+ return limit;
30
+ }
31
+
32
+ function queryOf(url: string): Query {
33
+ const params = new URL(url, 'http://localhost').searchParams;
34
+ for (const key of params.keys()) {
35
+ if (!['agent', 'limit', 'cursor'].includes(key) || params.getAll(key).length !== 1) throw new ApiError('invalid run events query', 400);
36
+ }
37
+ const agentId = params.get('agent') ?? '';
38
+ if (!ID_RE.test(agentId)) throw new ApiError('agent must be an agent id', 400);
39
+ return { agentId, limit: limitOf(params.get('limit')), cursor: cursorOf(params.get('cursor')) };
40
+ }
41
+
42
+ function policyOf(agentId: string, owner: string): string {
43
+ const accounts = knownAccounts().filter((account) => account.agentId === agentId).map(({ station, id }) => [
44
+ station, id, accountEnabled(station, id), allowlistForAccount(station, id) ?? [],
45
+ ]);
46
+ return createHash('sha256').update(JSON.stringify([owner, agentId, accounts])).digest('hex');
47
+ }
48
+
49
+ function bounded(events: RunEvent[], limit: number): RunEvent[] {
50
+ const rows: RunEvent[] = [];
51
+ let bytes = 0;
52
+ for (const event of events.slice(0, limit)) {
53
+ bytes += Buffer.byteLength(JSON.stringify(event)) + 1;
54
+ if (bytes > PAGE_BYTES_MAX) break;
55
+ rows.push(event);
56
+ }
57
+ return rows;
58
+ }
59
+
60
+ function resetNeeded(cursor: Cursor | null, policy: string, oldest: number, newest: number): boolean {
61
+ return cursor === null || cursor.epoch !== EPOCH || cursor.policy !== policy || cursor.seq < oldest - 1 || cursor.seq > newest;
62
+ }
63
+
64
+ function page(query: Query, owner: string): RunEventsPage {
65
+ const { agentId, limit, cursor } = query;
66
+ if (!knownAgentIds().includes(agentId)) throw new ApiError('no such agent', 404);
67
+ const policy = policyOf(agentId, owner);
68
+ const buffered = bufferedSince(0);
69
+ const newest = currentBusSeq();
70
+ const oldest = buffered[0]?.busSeq ?? newest + 1;
71
+ const reset = resetNeeded(cursor, policy, oldest, newest);
72
+ const visible = buffered.filter((entry) => reset || entry.busSeq > (cursor?.seq ?? newest)).flatMap((entry) => {
73
+ const event = runEvent(entry, agentId);
74
+ return event === null ? [] : [event];
75
+ });
76
+ const pending = reset ? visible.slice(-limit) : visible;
77
+ const events = bounded(pending, limit);
78
+ const hasMore = pending.length > events.length;
79
+ return {
80
+ events, cursor: `${EPOCH}:${policy}:${pageSeq(events, hasMore, newest)}`, hasMore, reset,
81
+ retention: retention(buffered, agentId),
82
+ };
83
+ }
84
+
85
+ const pageSeq = (events: RunEvent[], hasMore: boolean, newest: number): number => hasMore ? events.at(-1)?.seq ?? newest : newest;
86
+ function retention(buffered: BufferedEvent[], agentId: string): RunEventsPage['retention'] {
87
+ let oldestAt: string | null = null;
88
+ for (const entry of buffered) {
89
+ const event = runEvent(entry, agentId);
90
+ if (event === null) continue;
91
+ oldestAt = event.ts;
92
+ break;
93
+ }
94
+ return { capacity: BUS_BUFFER_MAX, oldestAt, oldestSeq: buffered[0]?.busSeq ?? null };
95
+ }
96
+
97
+ export function handleRunEventsRequest(req: IncomingMessage, res: ServerResponse): boolean {
98
+ return sessionRoute(req, res, {
99
+ methods: { '/api/run/events': ['GET'] }, admin: false, label: 'run-events',
100
+ }, (session) => Promise.resolve(page(queryOf(req.url ?? ''), session.subject)));
101
+ }
@@ -22,6 +22,7 @@ import { handleSchedulesRequest } from '../agent-user/schedules-api.js';
22
22
  import { handleFilesRequest } from '../agent-user/files-api.js';
23
23
  import { handleVaultRequest } from '../vault/api.js';
24
24
  import { handleVoiceRequest } from '../voice/api.js';
25
+ import { handleRunEventsRequest } from '../monitor/run-events.js';
25
26
 
26
27
  export interface SessionApis {
27
28
  agentApi?: AgentApiDeps;
@@ -50,6 +51,7 @@ export function handleSessionApis(
50
51
  ): boolean {
51
52
  const routes: (() => boolean)[] = [
52
53
  () => handleSessionApiRequest(req, res),
54
+ () => handleRunEventsRequest(req, res),
53
55
  () => handleApprovalsRequest(req, res),
54
56
  ...when(apis.connectorTransfers, (d) => d.handle(req, res)),
55
57
  ...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { log } from '@metro-labs/core/log';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
@@ -11,6 +11,7 @@ const INSTALL_TIMEOUT_MS = 15 * 60_000;
11
11
  export interface RuntimeManifest {
12
12
  core: Record<string, string>;
13
13
  stations: Record<string, Record<string, string>>;
14
+ patchedDependencies?: Record<string, string>;
14
15
  }
15
16
 
16
17
  export interface RuntimeStore {
@@ -30,6 +31,7 @@ export function readManifest(path: string): RuntimeManifest {
30
31
  return {
31
32
  core: ranges(raw.core),
32
33
  stations: Object.fromEntries(Object.entries(stations).map(([name, deps]) => [name, ranges(deps)])),
34
+ ...(raw.patchedDependencies === undefined ? {} : { patchedDependencies: ranges(raw.patchedDependencies) }),
33
35
  };
34
36
  }
35
37
 
@@ -46,8 +48,21 @@ export function dependenciesFor(manifest: RuntimeManifest, stations: Iterable<st
46
48
  return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
47
49
  }
48
50
 
49
- const packageText = (deps: Record<string, string>): string =>
50
- `${JSON.stringify({ name: 'metro-runtime', private: true, dependencies: deps }, null, 2)}\n`;
51
+ function packageText(deps: Record<string, string>, patches: Record<string, string>): string {
52
+ const patchedDependencies = Object.fromEntries(
53
+ Object.entries(deps).flatMap(([name, version]) => {
54
+ const key = `${name}@${version}`;
55
+ const path = patches[key];
56
+ return path === undefined ? [] : [[key, path]];
57
+ }),
58
+ );
59
+ return `${JSON.stringify({
60
+ name: 'metro-runtime',
61
+ private: true,
62
+ dependencies: deps,
63
+ ...(Object.keys(patchedDependencies).length === 0 ? {} : { patchedDependencies }),
64
+ }, null, 2)}\n`;
65
+ }
51
66
 
52
67
  function current(dir: string): string | null {
53
68
  try {
@@ -58,9 +73,10 @@ function current(dir: string): string | null {
58
73
  }
59
74
 
60
75
  export function installRuntime(store: RuntimeStore, stations: Iterable<string>): boolean {
61
- const wanted = packageText(dependenciesFor(store.manifest, stations));
62
- const installed = existsSync(join(store.dir, 'node_modules', '.metro-installed'));
63
- if (installed && current(store.dir) === wanted) return false;
76
+ const wanted = packageText(dependenciesFor(store.manifest, stations), store.manifest.patchedDependencies ?? {});
77
+ const marker = join(store.dir, 'node_modules', '.metro-installed');
78
+ if (existsSync(marker) && current(store.dir) === wanted) return false;
79
+ rmSync(marker, { force: true });
64
80
  mkdirSync(store.dir, { recursive: true });
65
81
  writeFileSync(join(store.dir, 'package.json'), wanted);
66
82
  log.info({ dir: store.dir }, 'runtime: installing the channel SDKs this machine needs');
@@ -71,7 +87,7 @@ export function installRuntime(store: RuntimeStore, stations: Iterable<string>):
71
87
  });
72
88
  if (run.error !== undefined || run.status !== 0)
73
89
  throw new Error(`bun install failed in ${store.dir}: ${run.error?.message ?? `exit ${String(run.status)}`}`);
74
- writeFileSync(join(store.dir, 'node_modules', '.metro-installed'), `${new Date().toISOString()}\n`);
90
+ writeFileSync(marker, `${new Date().toISOString()}\n`);
75
91
  return true;
76
92
  }
77
93
 
@@ -8,7 +8,7 @@ import {
8
8
  type StationHandler,
9
9
  } from '@metro-labs/core/stations/station-runtime';
10
10
  import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
11
- import { accountFor, accounts, targetOf } from './accounts.js';
11
+ import { accountFor, accounts, lineOf, targetOf } from './accounts.js';
12
12
  import { phoneNumberOf, senderLookup } from './resolve.js';
13
13
  import { assertImage, fieldsOf, parseProfileChange, type ProfileApplied } from '@metro-labs/core/stations/profile';
14
14
  import type { WAClient } from './client.js';
@@ -117,6 +117,35 @@ function makeSend(clientFor: ClientFor): StationHandler {
117
117
  };
118
118
  }
119
119
 
120
+ function makeRead(clientFor: ClientFor): StationHandler {
121
+ return async (id, args) => {
122
+ const { accountId, client, jid } = resolve(args, clientFor);
123
+ const page = await guard(() => client.read(jid, {
124
+ limit: typeof args.limit === 'number' ? args.limit : undefined,
125
+ before: str(args.before),
126
+ since: str(args.since),
127
+ }));
128
+ const messages = page.messages.map((row) => ({
129
+ id: row.messageId,
130
+ ts: row.timestamp,
131
+ ...(row.senderJid ? { from: lineOf(accountId, `user/${row.senderJid}`) } : {}),
132
+ self: row.fromMe,
133
+ text: row.text,
134
+ ...(row.truncated ? { truncated: true } : {}),
135
+ ...(row.attachments ? { attachments: row.attachments } : {}),
136
+ }));
137
+ respond(id, { result: { ...page, line: lineOf(accountId, jid), account: accountId, messages } });
138
+ };
139
+ }
140
+
141
+ function makeListMembers(clientFor: ClientFor): StationHandler {
142
+ return async (id, args) => {
143
+ const { client, jid } = resolve({ line: args.line }, clientFor);
144
+ const result = await guard(() => client.listMembers(jid, typeof args.limit === 'number' ? args.limit : undefined));
145
+ respond(id, { result });
146
+ };
147
+ }
148
+
120
149
  function makeReact(clientFor: ClientFor): StationHandler {
121
150
  return async (id, args) => {
122
151
  const { accountId, client, jid } = resolve(args, clientFor);
@@ -208,6 +237,8 @@ export function makeHandleCall(
208
237
  handlers: {
209
238
  accounts: makeAccounts(clientFor),
210
239
  send: makeSend(clientFor),
240
+ read: makeRead(clientFor),
241
+ listMembers: makeListMembers(clientFor),
211
242
  react: makeReact(clientFor),
212
243
  typing: makeTyping(clientFor),
213
244
  edit: makeEdit(clientFor),
@@ -0,0 +1,49 @@
1
+ import { errMsg, log } from '@metro-labs/core/log';
2
+ import { isPnUser, isLidUser, makeEventBuffer, processSyncAction, type BaileysEventEmitter, type Contact } from 'baileys';
3
+ import type { History } from './history.js';
4
+ import { baileysLogger } from './logger.js';
5
+
6
+ interface AppStateHooks {
7
+ history: History;
8
+ current(): boolean;
9
+ }
10
+
11
+ export function supportsHistoryDeletion(processAction = processSyncAction): boolean {
12
+ const ev = makeEventBuffer(baileysLogger('history-support'));
13
+ const id = 'history-support@g.us';
14
+ const messageRange = { lastMessageTimestamp: 1 };
15
+ let received = 0;
16
+ ev.on('chats.clear', (event) => {
17
+ if (event.id === id && event.messageRange === messageRange) received++;
18
+ });
19
+ try {
20
+ for (const initial of [false, true]) {
21
+ for (const type of ['clearChat', 'deleteChat']) {
22
+ const value = type === 'clearChat' ? { clearChatAction: { messageRange } } : { deleteChatAction: { messageRange } };
23
+ const before = received;
24
+ processAction({ index: [type, id], syncAction: { value } }, ev, { id }, initial ? { accountSettings: { unarchiveChats: false } } : undefined);
25
+ if (received !== before + 1) return false;
26
+ }
27
+ }
28
+ return true;
29
+ } catch (err) {
30
+ log.warn({ err: errMsg(err) }, 'whatsapp: local history deletion support check failed');
31
+ return false;
32
+ }
33
+ }
34
+
35
+ export function noteHistoryContact(history: History, contact: Partial<Contact>): void {
36
+ const pn = contact.phoneNumber ?? (isPnUser(contact.id) ? contact.id : undefined);
37
+ const lid = contact.lid ?? (isLidUser(contact.id) ? contact.id : undefined);
38
+ if (pn && lid) history.alias(pn, lid);
39
+ }
40
+
41
+ export function bindAppState(ev: BaileysEventEmitter, hooks: AppStateHooks): void {
42
+ ev.on('lid-mapping.update', ({ pn, lid }) => {
43
+ if (hooks.current()) hooks.history.alias(pn, lid);
44
+ });
45
+ ev.on('chats.clear', ({ id, messageRange }) => {
46
+ if (!hooks.current()) return;
47
+ hooks.history.clearRange(id, messageRange ?? {});
48
+ });
49
+ }