@stage-labs/metro 0.1.0-beta.238 → 0.1.0-beta.239

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.238",
3
+ "version": "0.1.0-beta.239",
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.238",
3
+ "version": "0.1.0-beta.239",
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",
@@ -52,7 +52,7 @@ import type { StationName } from '@metro-labs/core/station-names';
52
52
  import { startUploadReaper } from '../files/upload-store.js';
53
53
  import { startAttachReaper } from '../files/attach-reaper.js';
54
54
  import { startCloudWatchPublisher } from '../server/cloudwatch.js';
55
- import { isCallEvent, onCallEvent } from '../voice/calls.js';
55
+ import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } from '../voice/calls.js';
56
56
 
57
57
  installCrashGuard();
58
58
  acquireLock(join(STATE_DIR, '.tail-lock'));
@@ -137,6 +137,7 @@ installBearerSessions(agentsDir(), localOwner);
137
137
  await materializeFrom(fileSource);
138
138
  forgetOrphans(knownAccounts());
139
139
  supervisor.start();
140
+ sweepLeftoverCalls();
140
141
  const metroMcp = await createMetroMcp({ liveEvents: liveEvents() });
141
142
  webhookServer = await startWebhookServer(
142
143
  emit,
@@ -196,6 +197,7 @@ async function shutdown(): Promise<void> {
196
197
  if (shuttingDown) return;
197
198
  shuttingDown = true;
198
199
  log.info('dispatcher shutting down');
200
+ const leaving = leaveCallsForShutdown();
199
201
  unwatchSession();
200
202
  tunnel?.stop();
201
203
  if (webhookServer) {
@@ -211,6 +213,7 @@ async function shutdown(): Promise<void> {
211
213
  }),
212
214
  ]);
213
215
  }
216
+ await leaving;
214
217
  await supervisor.stop();
215
218
  process.exit(exitCode);
216
219
  }
@@ -10,6 +10,7 @@ const OFFER_WAIT_MS = 45_000;
10
10
  const LONGEST_CALL_MS = 60 * 60_000;
11
11
  const CONTEXT_MESSAGES = 12;
12
12
  const CONTEXT_CHARS = 400;
13
+ const LEAVE_RETRY_MS = 3_000;
13
14
 
14
15
  interface CallStart {
15
16
  line: string;
@@ -21,7 +22,7 @@ interface CallStart {
21
22
 
22
23
  const str = (value: unknown): string => (typeof value === 'string' ? value : '');
23
24
 
24
- async function trainCall(action: string, args: Record<string, unknown>): Promise<unknown> {
25
+ export async function trainCall(action: string, args: Record<string, unknown>): Promise<unknown> {
25
26
  const answer = await forwardTrainCall('xmtp', action, args);
26
27
  if (answer.error !== undefined) throw new Error(answer.error);
27
28
  return answer.result;
@@ -57,6 +58,7 @@ export class Call {
57
58
  private timer: ReturnType<typeof setTimeout> | null = null;
58
59
  private longest: ReturnType<typeof setTimeout>;
59
60
  private over = false;
61
+ private left: Promise<void> = Promise.resolve();
60
62
 
61
63
  constructor(
62
64
  readonly start: CallStart,
@@ -93,11 +95,16 @@ export class Call {
93
95
  this.answer(this.start.callerPeer, raw.sdp).catch((err: unknown) => {
94
96
  this.hangUp(`answering failed: ${errMsg(err)}`);
95
97
  });
96
- else if (raw.kind === 'leave' && fromCaller) this.end('the caller hung up', false);
98
+ else if (raw.kind === 'leave' && fromCaller) this.hangUp('the caller hung up');
97
99
  }
98
100
 
99
101
  hangUp(reason: string): void {
100
- this.end(reason, true);
102
+ this.end(reason);
103
+ }
104
+
105
+ leave(reason: string): Promise<void> {
106
+ this.end(reason);
107
+ return this.left;
101
108
  }
102
109
 
103
110
  private async answer(to: string, offer: string): Promise<void> {
@@ -125,13 +132,15 @@ export class Call {
125
132
  return trainCall('callSignal', { line: this.start.line, signal });
126
133
  }
127
134
 
128
- private end(reason: string, sendLeave: boolean): void {
135
+ private end(reason: string): void {
129
136
  if (this.over) return;
130
137
  this.over = true;
131
138
  if (this.timer !== null) clearTimeout(this.timer);
132
139
  clearTimeout(this.longest);
133
140
  log.info({ reason, seconds: Math.round((Date.now() - this.startedAt) / 1000) }, 'voice: call over');
134
- this.wrapUp(reason, sendLeave)
141
+ this.left = this.sendLeave();
142
+ this.left
143
+ .then(() => this.wrapUp(reason))
135
144
  .catch((err: unknown) => {
136
145
  log.warn({ err: errMsg(err) }, 'voice: wrapping up the call failed');
137
146
  })
@@ -140,11 +149,20 @@ export class Call {
140
149
  });
141
150
  }
142
151
 
143
- private async wrapUp(reason: string, sendLeave: boolean): Promise<void> {
144
- if (sendLeave)
145
- await this.signal({ kind: 'leave', callId: this.start.callId, from: this.selfPeer }).catch((err: unknown) => {
146
- log.warn({ err: errMsg(err) }, 'voice: could not send the hang-up');
152
+ private async sendLeave(): Promise<void> {
153
+ const leave = { kind: 'leave', callId: this.start.callId, from: this.selfPeer };
154
+ try {
155
+ await this.signal(leave);
156
+ } catch (err) {
157
+ log.warn({ err: errMsg(err) }, 'voice: could not send the hang-up, trying once more');
158
+ await new Promise((r) => setTimeout(r, LEAVE_RETRY_MS));
159
+ await this.signal(leave).catch((again: unknown) => {
160
+ log.warn({ err: errMsg(again) }, 'voice: could not send the hang-up; the next start sends it');
147
161
  });
162
+ }
163
+ }
164
+
165
+ private async wrapUp(reason: string): Promise<void> {
148
166
  this.peer?.close();
149
167
  const notes = await this.talk.finish();
150
168
  const minutes = Math.max(1, Math.round((Date.now() - this.startedAt) / 60_000));
@@ -2,11 +2,15 @@ import { errMsg, log } from '@metro-labs/core/log';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import type { TrainEvent } from '@metro-labs/core/trains/protocol';
4
4
  import { lineReceives, mayApprove } from '../agents/map.js';
5
- import { Call } from './call.js';
5
+ import { Call, trainCall } from './call.js';
6
6
  import { readVoice, voiceReady } from './store.js';
7
7
 
8
- const INVITE_FRESH_MS = 60_000;
8
+ const RING_MS = 40_000;
9
9
  const CALL_TYPES = new Set(['callInvite', 'callSignal']);
10
+ const SHUTDOWN_LEAVE_MS = 2_000;
11
+ const SWEEP_FIRST_MS = 5_000;
12
+ const SWEEP_EVERY_MS = 10_000;
13
+ const SWEEP_TRIES = 12;
10
14
 
11
15
  let current: Call | null = null;
12
16
 
@@ -17,26 +21,43 @@ const contentTypeOf = (env: TrainEvent): string => str(record(env.payload).conte
17
21
 
18
22
  export const isCallEvent = (env: TrainEvent): boolean => CALL_TYPES.has(contentTypeOf(env));
19
23
 
20
- function refusal(env: TrainEvent, line: string, from: string): string | null {
21
- if (current !== null) return 'already in a call';
22
- const sent = Date.parse(str(env.ts));
23
- if (Number.isNaN(sent) || Date.now() - sent > INVITE_FRESH_MS) return 'the invite is too old';
24
+ const sameCaller = (call: Call, line: string, from: string): boolean => call.start.line === line && call.start.from === from;
25
+
26
+ function refusal(line: string, from: string): string | null {
27
+ if (current !== null && !sameCaller(current, line, from)) return 'already in a call';
24
28
  if (!voiceReady(readVoice())) return 'voice is off or has no API key on the Voice page';
25
29
  if (!lineReceives(line) || !mayApprove(line, from)) return 'the caller is not someone who can approve for this channel';
26
30
  return null;
27
31
  }
28
32
 
33
+ function decline(line: string, callId: string): void {
34
+ trainCall('callSignal', { line, signal: { kind: 'decline', callId } }).catch((err: unknown) => {
35
+ log.warn({ line, err: errMsg(err) }, 'voice: could not decline a call');
36
+ });
37
+ }
38
+
39
+ function ringing(env: TrainEvent): boolean {
40
+ const sent = Date.parse(str(env.ts));
41
+ return !Number.isNaN(sent) && Date.now() - sent < RING_MS;
42
+ }
43
+
29
44
  function invite(env: TrainEvent, body: Record<string, unknown>): void {
30
45
  const line = str(env.line);
31
46
  const from = str(env.from);
32
47
  const callId = str(body.callId);
33
48
  const callerPeer = str(body.from);
34
- if (callId === '' || callerPeer === '') return;
35
- const refused = refusal(env, line, from);
49
+ if (callId === '' || callerPeer === '' || current?.start.callId === callId) return;
50
+ if (!ringing(env)) {
51
+ log.info({ line }, 'voice: an invite came in after the caller stopped ringing, not answered');
52
+ return;
53
+ }
54
+ const refused = refusal(line, from);
36
55
  if (refused !== null) {
37
- log.info({ line, reason: refused }, 'voice: a call came in and is not answered');
56
+ log.info({ line, reason: refused }, 'voice: a call came in and is declined');
57
+ decline(line, callId);
38
58
  return;
39
59
  }
60
+ current?.hangUp('the caller started a new call');
40
61
  const callerName = str(env.from_display_name) || str(env.from_name) || 'the caller';
41
62
  const call = new Call({ line, from, callerName, callId, callerPeer }, readVoice(), () => {
42
63
  if (current === call) current = null;
@@ -52,3 +73,57 @@ export function onCallEvent(env: TrainEvent): void {
52
73
  if (contentTypeOf(env) === 'callInvite') invite(env, body);
53
74
  else if (current !== null && env.line === current.start.line) current.signalled(body);
54
75
  }
76
+
77
+ export function leaveCallsForShutdown(): Promise<void> {
78
+ if (current === null) return Promise.resolve();
79
+ const leaving = current.leave('Metro stopped or restarted');
80
+ const capped = new Promise<void>((resolve) => {
81
+ setTimeout(resolve, SHUTDOWN_LEAVE_MS).unref();
82
+ });
83
+ return Promise.race([leaving, capped]);
84
+ }
85
+
86
+ interface Leftover {
87
+ line: string;
88
+ callId: string;
89
+ peer: string;
90
+ }
91
+
92
+ function leftoversOf(result: unknown): Leftover[] {
93
+ const rows = isRecord(result) && Array.isArray(result.calls) ? result.calls : [];
94
+ return rows
95
+ .map((row) => record(row))
96
+ .map((row) => ({ line: str(row.line), callId: str(row.callId), peer: str(row.peer) }))
97
+ .filter((row) => row.line !== '' && row.callId !== '' && row.peer !== '');
98
+ }
99
+
100
+ export async function leaveLeftoverCalls(): Promise<number> {
101
+ const leftovers = leftoversOf(await trainCall('callLeftovers', {}));
102
+ let left = 0;
103
+ for (const { line, callId, peer } of leftovers) {
104
+ if (current?.start.callId === callId) continue;
105
+ try {
106
+ await trainCall('callSignal', { line, signal: { kind: 'leave', callId, from: peer } });
107
+ log.info({ line, callId }, 'voice: left a call this box was still in');
108
+ left += 1;
109
+ } catch (err) {
110
+ log.warn({ line, err: errMsg(err) }, 'voice: could not leave a call this box was still in');
111
+ }
112
+ }
113
+ return left;
114
+ }
115
+
116
+ function sweep(tries: number, delayMs: number): void {
117
+ setTimeout(() => {
118
+ leaveLeftoverCalls().catch((err: unknown) => {
119
+ const reason = errMsg(err);
120
+ if (reason.startsWith('no train named')) return;
121
+ if (tries > 1) sweep(tries - 1, SWEEP_EVERY_MS);
122
+ else log.warn({ err: reason }, 'voice: could not check for calls left open');
123
+ });
124
+ }, delayMs).unref();
125
+ }
126
+
127
+ export function sweepLeftoverCalls(): void {
128
+ sweep(SWEEP_TRIES, SWEEP_FIRST_MS);
129
+ }
@@ -14,6 +14,7 @@ import { resolveMsgId } from './wire.js';
14
14
  import { emitOutbound } from './emit.js';
15
15
  import { CallSignalCodec, FrameCodec, PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
16
16
  import { buildFrameContent } from './frames.js';
17
+ import { callLeftovers } from './call-leftovers.js';
17
18
  import { convHandlers } from './actions-conv.js';
18
19
  import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
19
20
  import { TrainError } from '@metro-labs/core/train-error';
@@ -233,6 +234,7 @@ const handlers: Record<string, (id: string, args: Args) => Promise<void>> = {
233
234
  reply,
234
235
  delete: remove,
235
236
  callSignal,
237
+ callLeftovers: (id) => callLeftovers(id),
236
238
  sendAttachment,
237
239
  sendImage,
238
240
  ...convHandlers,
@@ -0,0 +1,47 @@
1
+ import type { DecodedMessage } from '@xmtp/node-sdk';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { TrainError } from '@metro-labs/core/train-error';
4
+ import { respond } from '@metro-labs/core/stations/station-runtime';
5
+ import { accounts, lineOf, type Account } from './accounts.js';
6
+ import { isCallSignal } from './codecs.js';
7
+
8
+ const CALL_WINDOW_MS = 2 * 60 * 60_000;
9
+
10
+ interface Leftover {
11
+ line: string;
12
+ callId: string;
13
+ peer: string;
14
+ }
15
+
16
+ function ownSignal(acct: Account, m: DecodedMessage): Record<string, unknown> | null {
17
+ if (m.senderInboxId !== acct.inboxId || !isCallSignal(m)) return null;
18
+ return isRecord(m.content) ? m.content : null;
19
+ }
20
+
21
+ function openJoins(acct: Account, line: string, messages: DecodedMessage[]): Leftover[] {
22
+ const open = new Map<string, string>();
23
+ for (const m of messages) {
24
+ const signal = ownSignal(acct, m);
25
+ if (signal === null || typeof signal.from !== 'string' || typeof signal.callId !== 'string') continue;
26
+ if (signal.kind === 'join') open.set(signal.from, signal.callId);
27
+ else if (signal.kind === 'leave') open.delete(signal.from);
28
+ }
29
+ return [...open].map(([peer, callId]) => ({ line, callId, peer }));
30
+ }
31
+
32
+ async function leftoversOf(acct: Account, sinceNs: bigint): Promise<Leftover[]> {
33
+ const found: Leftover[] = [];
34
+ for (const conv of await acct.client.conversations.list()) {
35
+ const messages = await conv.messages({ sentAfterNs: sinceNs, direction: 0 });
36
+ found.push(...openJoins(acct, lineOf(acct.cfg.id, conv.id), messages));
37
+ }
38
+ return found;
39
+ }
40
+
41
+ export async function callLeftovers(id: string): Promise<void> {
42
+ if (accounts.size === 0) throw new TrainError('UNAVAILABLE', 'no XMTP account is ready yet');
43
+ const sinceNs = BigInt(Date.now() - CALL_WINDOW_MS) * 1_000_000n;
44
+ const calls: Leftover[] = [];
45
+ for (const acct of accounts.values()) calls.push(...(await leftoversOf(acct, sinceNs)));
46
+ respond(id, { result: { calls } });
47
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.238"
2
+ "version": "0.1.0-beta.239"
3
3
  }