@stage-labs/metro 0.1.0-beta.263 → 0.1.0-beta.265
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/dist/cli.js +2 -2
- package/dist/runtime-install.js +22 -5
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/policy.mjs +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/call.ts +81 -0
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +35 -2
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +14 -11
- package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-bridge.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-permission.ts +25 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/event-store.ts +9 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +15 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +20 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-schema.ts +11 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-send.ts +15 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/runtime-deps.ts +23 -7
- package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +50 -10
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +20 -9
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +125 -51
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +41 -15
- package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +9 -8
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +15 -3
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared-talk.ts +85 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +199 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech-queue.ts +173 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +40 -15
- package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +15 -7
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +32 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/app-state.ts +49 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-history.ts +31 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +46 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +75 -77
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-events.ts +127 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-files.ts +39 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-identity.ts +58 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-message.ts +161 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-state.ts +344 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-types.ts +91 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history.ts +195 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +1 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/members.ts +94 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +70 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/send-expiry.ts +65 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
- package/runtime/patches/5bca2c07d5852d3f58453cff0448dd85da13d3665b61e3d758905b298d432d7c.patch +31 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/call.ts +81 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/activity.ts +14 -2
- package/runtime/sdk-runner/src/app.ts +5 -4
- package/runtime/sdk-runner/src/approvals.ts +14 -5
- package/runtime/sdk-runner/src/calls.ts +133 -0
- package/runtime/sdk-runner/src/inbox.ts +58 -26
- package/runtime/sdk-runner/src/input-queue.ts +70 -0
- package/runtime/sdk-runner/src/input-timing.ts +68 -0
- package/runtime/sdk-runner/src/link.ts +27 -4
- package/runtime/sdk-runner/src/main.ts +0 -4
- package/runtime/sdk-runner/src/rules.ts +19 -5
- package/runtime/sdk-runner/src/runner.ts +64 -88
- package/runtime/sdk-runner/src/session-store.ts +14 -1
- package/runtime/sdk-runner/src/session-watch.ts +27 -20
- package/runtime/stations.json +3 -0
- package/runtime/sdk-runner/src/speech.ts +0 -79
package/dist/cli.js
CHANGED
|
@@ -34,9 +34,9 @@ const USAGE = `metro: run your agent on this machine
|
|
|
34
34
|
open Claude Code with the metro channel and the metro MCP server, its
|
|
35
35
|
inference routed through the daemon's model gateway (the Model page);
|
|
36
36
|
every argument is passed through the same
|
|
37
|
-
metro agent run chat and background workers in one Agent SDK conversation,
|
|
37
|
+
metro agent run chat, calls and background workers in one Agent SDK conversation,
|
|
38
38
|
when the Harness runner is Agent SDK, using the Model page's model;
|
|
39
|
-
resume it after a restart;
|
|
39
|
+
resume it after a restart; call audio runs independently
|
|
40
40
|
metro agent --prepare [cli|sdk]
|
|
41
41
|
check the runner before selecting it; install the SDK runtime if needed,
|
|
42
42
|
without starting a session or connecting to chat
|
package/dist/runtime-install.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.265",
|
|
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 [];
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"./events": "./src/events.ts",
|
|
10
10
|
"./is-record": "./src/is-record.ts",
|
|
11
11
|
"./runner-activity": "./src/runner-activity.ts",
|
|
12
|
+
"./call": "./src/call.ts",
|
|
12
13
|
"./secure-fs": "./src/secure-fs.ts",
|
|
13
14
|
"./version": "./src/version.ts",
|
|
14
15
|
"./train-error": "./src/train-error.ts",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isRecord } from './is-record.js';
|
|
2
|
+
|
|
3
|
+
export const CALL_NOTICE = 'notifications/metro/call';
|
|
4
|
+
export const CALL_STATE = 'notifications/metro/call_state';
|
|
5
|
+
|
|
6
|
+
export interface CallState {
|
|
7
|
+
route: CallRoute | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CallRoute {
|
|
11
|
+
agentId: string;
|
|
12
|
+
line: string;
|
|
13
|
+
from: string;
|
|
14
|
+
callId: string;
|
|
15
|
+
generation: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SpeechTarget {
|
|
19
|
+
callId: string;
|
|
20
|
+
generation: string;
|
|
21
|
+
sourceId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CallSource {
|
|
25
|
+
route: CallRoute;
|
|
26
|
+
sourceId: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SpeechStatus = 'accepted' | 'queued' | 'started' | 'completed' | 'interrupted' | 'failed';
|
|
30
|
+
|
|
31
|
+
export type CallNotice =
|
|
32
|
+
| { type: 'started'; route: CallRoute; sourceId: string }
|
|
33
|
+
| { type: 'heard'; route: CallRoute; sourceId: string; text: string }
|
|
34
|
+
| { type: 'ended'; route: CallRoute }
|
|
35
|
+
| { type: 'speech'; route: CallRoute; sourceId: string; actionId: string; status: SpeechStatus };
|
|
36
|
+
|
|
37
|
+
const filled = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 2048;
|
|
38
|
+
|
|
39
|
+
export function callRoute(value: unknown): CallRoute | null {
|
|
40
|
+
if (!isRecord(value)) return null;
|
|
41
|
+
const { agentId, line, from, callId, generation } = value;
|
|
42
|
+
return filled(agentId) && filled(line) && filled(from) && filled(callId) && filled(generation)
|
|
43
|
+
? { agentId, line, from, callId, generation }
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function callState(value: unknown): CallState | null {
|
|
48
|
+
if (!isRecord(value)) return null;
|
|
49
|
+
if (value.route === null) return { route: null };
|
|
50
|
+
const route = callRoute(value.route);
|
|
51
|
+
return route === null ? null : { route };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function speechTarget(value: unknown): SpeechTarget | null {
|
|
55
|
+
if (!isRecord(value)) return null;
|
|
56
|
+
const { callId, generation, sourceId } = value;
|
|
57
|
+
return filled(callId) && filled(generation) && filled(sourceId) ? { callId, generation, sourceId } : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const STATUSES: readonly SpeechStatus[] = ['accepted', 'queued', 'started', 'completed', 'interrupted', 'failed'];
|
|
61
|
+
|
|
62
|
+
const speechStatus = (value: unknown): SpeechStatus | null => STATUSES.find((status) => status === value) ?? null;
|
|
63
|
+
|
|
64
|
+
function sourcedNotice(value: Record<string, unknown>, route: CallRoute, sourceId: string): CallNotice | null {
|
|
65
|
+
if (value.type === 'started') return { type: 'started', route, sourceId };
|
|
66
|
+
if (value.type === 'heard') return typeof value.text === 'string' && value.text.length <= 32_000 ? { type: 'heard', route, sourceId, text: value.text } : null;
|
|
67
|
+
if (value.type !== 'speech' || !filled(value.actionId)) return null;
|
|
68
|
+
const status = speechStatus(value.status);
|
|
69
|
+
return status === null ? null : { type: 'speech', route, sourceId, actionId: value.actionId, status };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function callNotice(value: unknown): CallNotice | null {
|
|
73
|
+
if (!isRecord(value)) return null;
|
|
74
|
+
const route = callRoute(value.route);
|
|
75
|
+
if (route === null) return null;
|
|
76
|
+
if (value.type === 'ended') return { type: 'ended', route };
|
|
77
|
+
return filled(value.sourceId) ? sourcedNotice(value, route, value.sourceId) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const sameCall = (a: CallRoute, b: CallRoute): boolean =>
|
|
81
|
+
a.agentId === b.agentId && a.line === b.line && a.from === b.from && a.callId === b.callId && a.generation === b.generation;
|
|
@@ -74,6 +74,17 @@ export interface RunnerFailure extends RunnerEvent {
|
|
|
74
74
|
code: RunnerFailureCode;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export interface RunnerInput {
|
|
78
|
+
id: string;
|
|
79
|
+
kind: 'chat' | 'call' | 'note';
|
|
80
|
+
state: 'accepted' | 'consumed' | 'output' | 'completed' | 'cancelled';
|
|
81
|
+
acceptedAt: number;
|
|
82
|
+
dispatchedAt: number | null;
|
|
83
|
+
consumedAt: number | null;
|
|
84
|
+
firstOutputAt: number | null;
|
|
85
|
+
completedAt: number | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
77
88
|
export interface RunnerActivity {
|
|
78
89
|
pid: number;
|
|
79
90
|
procStart?: string;
|
|
@@ -84,6 +95,9 @@ export interface RunnerActivity {
|
|
|
84
95
|
sessionId: string | null;
|
|
85
96
|
updatedAt: number;
|
|
86
97
|
pending: number;
|
|
98
|
+
queueOldestAt?: number | null;
|
|
99
|
+
inputs?: RunnerInput[];
|
|
100
|
+
callState?: 'started' | 'ended' | 'accepted' | 'queued' | 'speaking' | 'completed' | 'interrupted' | 'failed';
|
|
87
101
|
workers: number;
|
|
88
102
|
approvals: number;
|
|
89
103
|
tools: string[];
|
|
@@ -160,6 +174,28 @@ function failureFields(raw: Record<string, unknown>): Pick<RunnerActivity, 'last
|
|
|
160
174
|
};
|
|
161
175
|
}
|
|
162
176
|
|
|
177
|
+
function input(raw: unknown): RunnerInput | null {
|
|
178
|
+
if (!isRecord(raw) || sessionId(raw.id) === null) return null;
|
|
179
|
+
const kind = (['chat', 'call', 'note'] as const).find((value) => value === raw.kind);
|
|
180
|
+
const state = (['accepted', 'consumed', 'output', 'completed', 'cancelled'] as const).find((value) => value === raw.state);
|
|
181
|
+
if (kind === undefined || state === undefined) return null;
|
|
182
|
+
return {
|
|
183
|
+
id: String(raw.id), kind, state, acceptedAt: count(raw.acceptedAt),
|
|
184
|
+
dispatchedAt: count(raw.dispatchedAt) || null, consumedAt: count(raw.consumedAt) || null,
|
|
185
|
+
firstOutputAt: count(raw.firstOutputAt) || null, completedAt: count(raw.completedAt) || null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function inputFields(raw: Record<string, unknown>): Pick<RunnerActivity, 'inputs' | 'queueOldestAt' | 'callState'> {
|
|
190
|
+
const states = ['started', 'ended', 'accepted', 'queued', 'speaking', 'completed', 'interrupted', 'failed'] as const;
|
|
191
|
+
const callState = states.find((state) => state === raw.callState);
|
|
192
|
+
return {
|
|
193
|
+
...(raw.inputs === undefined ? {} : { inputs: list(raw.inputs, input, 40) }),
|
|
194
|
+
...(raw.queueOldestAt === undefined ? {} : { queueOldestAt: count(raw.queueOldestAt) || null }),
|
|
195
|
+
...(callState === undefined ? {} : { callState }),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
163
199
|
export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
|
|
164
200
|
if (!isRecord(raw) || raw.runner !== 'sdk' || !phase(raw.phase) || count(raw.pid) === 0 || count(raw.updatedAt) === 0) return null;
|
|
165
201
|
return {
|
|
@@ -168,6 +204,6 @@ export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
|
|
|
168
204
|
pending: count(raw.pending), workers: count(raw.workers), approvals: count(raw.approvals),
|
|
169
205
|
tools: list(raw.tools, name, 20), activeTools: list(raw.activeTools, tool, 20),
|
|
170
206
|
tasks: list(raw.tasks, task, 30), events: list(raw.events, event, 40),
|
|
171
|
-
...failureFields(raw),
|
|
207
|
+
...failureFields(raw), ...inputFields(raw),
|
|
172
208
|
};
|
|
173
209
|
}
|
|
@@ -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);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
2
|
import { previewMatches } from './preview.js';
|
|
3
|
+
import { sameCall, speechTarget, type CallRoute, type CallSource } from '@metro-labs/core/call';
|
|
3
4
|
|
|
4
5
|
export const NEEDS_APPROVAL = (where: string, tool: string): string =>
|
|
5
6
|
`Needs the owner's approval for ${where} (${tool}). Make this exact call from a background worker so Claude Code asks the owner in chat, and wait for the answer. An approval given only in the terminal does not count.`;
|
|
@@ -15,6 +16,7 @@ export interface PendingPrompt {
|
|
|
15
16
|
preview: string;
|
|
16
17
|
line: string | undefined;
|
|
17
18
|
at: number;
|
|
19
|
+
call?: CallSource;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
interface Held extends PendingPrompt {
|
|
@@ -33,6 +35,9 @@ interface Grant {
|
|
|
33
35
|
tool: string;
|
|
34
36
|
preview: string;
|
|
35
37
|
at: number;
|
|
38
|
+
owner: object;
|
|
39
|
+
requestId: string;
|
|
40
|
+
call?: CallSource;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
const GRANT_TTL_MS = 10 * 60_000;
|
|
@@ -40,11 +45,22 @@ const GRANTS_MAX = 200;
|
|
|
40
45
|
let grants: Grant[] = [];
|
|
41
46
|
|
|
42
47
|
function grant(entry: Held, now = Date.now()): void {
|
|
43
|
-
grants = [...grants.filter((g) => now - g.at < GRANT_TTL_MS), {
|
|
48
|
+
grants = [...grants.filter((g) => now - g.at < GRANT_TTL_MS), {
|
|
49
|
+
tool: entry.tool, preview: entry.preview, at: now, owner: entry.owner, requestId: entry.requestId,
|
|
50
|
+
...(entry.call === undefined ? {} : { call: entry.call }),
|
|
51
|
+
}].slice(-GRANTS_MAX);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function speechMatches(grant: Grant, args: Record<string, unknown>): boolean {
|
|
55
|
+
if (args.speech === undefined) return grant.call === undefined;
|
|
56
|
+
const target = speechTarget(args.speech);
|
|
57
|
+
const bound = grant.call;
|
|
58
|
+
return target !== null && bound !== undefined && bound.route.line === args.line && bound.route.callId === target.callId &&
|
|
59
|
+
bound.route.generation === target.generation && bound.sourceId === target.sourceId;
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
export function takeGrant(toolMatches: (tool: string) => boolean, args: Record<string, unknown>, now = Date.now()): boolean {
|
|
47
|
-
const at = grants.findIndex((g) => now - g.at < GRANT_TTL_MS && toolMatches(g.tool) && previewMatches(g.preview, args));
|
|
63
|
+
const at = grants.findIndex((g) => now - g.at < GRANT_TTL_MS && toolMatches(g.tool) && speechMatches(g, args) && previewMatches(g.preview, args));
|
|
48
64
|
if (at < 0) return false;
|
|
49
65
|
grants = grants.filter((_, i) => i !== at);
|
|
50
66
|
return true;
|
|
@@ -109,6 +125,23 @@ export async function answerPrompt(
|
|
|
109
125
|
|
|
110
126
|
export function forgetPromptsOf(owner: object): void {
|
|
111
127
|
for (const [id, entry] of held) if (entry.owner === owner) held.delete(id);
|
|
128
|
+
grants = grants.filter((entry) => entry.owner !== owner);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function cancelPrompt(requestId: string, owner: object): void {
|
|
132
|
+
grants = grants.filter((entry) => entry.owner !== owner || entry.requestId !== requestId);
|
|
133
|
+
const entry = held.get(requestId);
|
|
134
|
+
if (entry?.owner !== owner) return;
|
|
135
|
+
held.delete(requestId);
|
|
136
|
+
entry.send('deny').catch((err: unknown) => {
|
|
137
|
+
log.warn({ requestId, err: errMsg(err) }, 'approvals: cancelled prompt could not be answered');
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function revokeCallPrompts(route: CallRoute): void {
|
|
142
|
+
grants = grants.filter((entry) => entry.call === undefined || !sameCall(entry.call.route, route));
|
|
143
|
+
for (const entry of held.values())
|
|
144
|
+
if (entry.call !== undefined && sameCall(entry.call.route, route)) cancelPrompt(entry.requestId, entry.owner);
|
|
112
145
|
}
|
|
113
146
|
|
|
114
147
|
export function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
|
|
@@ -51,6 +51,7 @@ interface InboundDeps {
|
|
|
51
51
|
getStations: () => Set<string>;
|
|
52
52
|
senderAllowed: (from: string, line: string, verified?: boolean) => boolean;
|
|
53
53
|
approves?: (station: string) => boolean;
|
|
54
|
+
callMeta?: (event: Record<string, unknown>) => () => Record<string, string>;
|
|
54
55
|
answerPermission?: (requestId: string, behavior: 'allow' | 'deny', line: string, from: string) => Promise<boolean>;
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -91,12 +92,7 @@ export class InboundRelay {
|
|
|
91
92
|
return this.deps.mcp.notification({ method, params });
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
private isDuplicate(
|
|
95
|
-
station: string,
|
|
96
|
-
line: string,
|
|
97
|
-
kind: string,
|
|
98
|
-
messageId: string,
|
|
99
|
-
): boolean {
|
|
95
|
+
private isDuplicate(station: string, line: string, kind: string, messageId: string): boolean {
|
|
100
96
|
if (!messageId) return false;
|
|
101
97
|
const key = dedupeKey(station, line, kind, messageId);
|
|
102
98
|
const now = Date.now();
|
|
@@ -129,6 +125,7 @@ export class InboundRelay {
|
|
|
129
125
|
mime: p.mime ?? '',
|
|
130
126
|
name: note.name,
|
|
131
127
|
...extra,
|
|
128
|
+
...ctx.callMeta?.(),
|
|
132
129
|
},
|
|
133
130
|
});
|
|
134
131
|
}
|
|
@@ -172,6 +169,7 @@ export class InboundRelay {
|
|
|
172
169
|
from_name: e.fromName,
|
|
173
170
|
...displayNameMeta(e.fromDisplayName),
|
|
174
171
|
...e.reply,
|
|
172
|
+
...e.callMeta?.(),
|
|
175
173
|
},
|
|
176
174
|
});
|
|
177
175
|
}
|
|
@@ -201,13 +199,14 @@ export class InboundRelay {
|
|
|
201
199
|
};
|
|
202
200
|
}
|
|
203
201
|
|
|
204
|
-
private async routeAttachment(ev: Record<string, unknown
|
|
202
|
+
private async routeAttachment(ev: Record<string, unknown>, replay: boolean): Promise<boolean> {
|
|
205
203
|
const p = ev.payload as SavedMedia | undefined;
|
|
206
204
|
if (!p) return false;
|
|
207
205
|
const type = p.contentType;
|
|
208
206
|
if (type !== 'attachmentSaved' && type !== 'attachmentFailed') return false;
|
|
209
207
|
const ctx = this.mediaCtxFor(ev, p);
|
|
210
208
|
if (!ctx) return true;
|
|
209
|
+
if (replay) ctx.callMeta = undefined;
|
|
211
210
|
if (type === 'attachmentFailed') await this.surfaceMediaFailure(ctx, p);
|
|
212
211
|
else await this.surfaceMedia(ctx, p);
|
|
213
212
|
return true;
|
|
@@ -215,8 +214,9 @@ export class InboundRelay {
|
|
|
215
214
|
|
|
216
215
|
private bufferAttachments(
|
|
217
216
|
ev: Record<string, unknown>,
|
|
218
|
-
base:
|
|
217
|
+
base: EventBase,
|
|
219
218
|
atts: PendingAtt[],
|
|
219
|
+
replay: boolean,
|
|
220
220
|
): void {
|
|
221
221
|
const id = str(ev.id);
|
|
222
222
|
if (!id) return;
|
|
@@ -230,6 +230,7 @@ export class InboundRelay {
|
|
|
230
230
|
fromName: str(ev.fromName),
|
|
231
231
|
fromDisplayName: str(ev.fromDisplayName),
|
|
232
232
|
reply: replyMeta(ev, this.sentIds),
|
|
233
|
+
callMeta: !replay && base.evType === 'msg' ? this.deps.callMeta?.(ev) : undefined,
|
|
233
234
|
attachments: atts.map((a) => ({ kind: a.kind, name: a.name })),
|
|
234
235
|
saved: new Set<number>(),
|
|
235
236
|
timer: setTimeout(() => {
|
|
@@ -337,6 +338,7 @@ export class InboundRelay {
|
|
|
337
338
|
private async emitMessage(
|
|
338
339
|
ev: Record<string, unknown>,
|
|
339
340
|
base: EventBase,
|
|
341
|
+
replay: boolean,
|
|
340
342
|
): Promise<void> {
|
|
341
343
|
if (base.evType === 'msg' && base.text.trim() === '') {
|
|
342
344
|
this.deps.log('drop: empty message', base.station, base.line, str(ev.messageId));
|
|
@@ -358,6 +360,7 @@ export class InboundRelay {
|
|
|
358
360
|
from_name: str(ev.fromName),
|
|
359
361
|
...displayNameMeta(ev.fromDisplayName),
|
|
360
362
|
...replyMeta(ev, this.sentIds),
|
|
363
|
+
...(!replay && base.evType === 'msg' ? this.deps.callMeta?.(ev)?.() : {}),
|
|
361
364
|
},
|
|
362
365
|
});
|
|
363
366
|
}
|
|
@@ -366,7 +369,7 @@ export class InboundRelay {
|
|
|
366
369
|
ev: Record<string, unknown>,
|
|
367
370
|
replay = false,
|
|
368
371
|
): Promise<void> {
|
|
369
|
-
if (await this.routeAttachment(ev)) return;
|
|
372
|
+
if (await this.routeAttachment(ev, replay)) return;
|
|
370
373
|
|
|
371
374
|
const base = this.routable(ev, replay);
|
|
372
375
|
if (!base) return;
|
|
@@ -375,7 +378,7 @@ export class InboundRelay {
|
|
|
375
378
|
const atts = (ev.payload as { attachments?: PendingAtt[] } | undefined)
|
|
376
379
|
?.attachments;
|
|
377
380
|
if (Array.isArray(atts) && atts.length) {
|
|
378
|
-
this.bufferAttachments(ev, base, atts);
|
|
381
|
+
this.bufferAttachments(ev, base, atts, replay);
|
|
379
382
|
return;
|
|
380
383
|
}
|
|
381
384
|
|
|
@@ -383,7 +386,7 @@ export class InboundRelay {
|
|
|
383
386
|
await this.handleReact(ev, base);
|
|
384
387
|
return;
|
|
385
388
|
}
|
|
386
|
-
await this.emitMessage(ev, base);
|
|
389
|
+
await this.emitMessage(ev, base, replay);
|
|
387
390
|
}
|
|
388
391
|
}
|
|
389
392
|
|
|
@@ -16,6 +16,7 @@ export interface PendingMsg {
|
|
|
16
16
|
fromName: string;
|
|
17
17
|
fromDisplayName: string;
|
|
18
18
|
reply: Record<string, string>;
|
|
19
|
+
callMeta?: () => Record<string, string>;
|
|
19
20
|
attachments: PendingAtt[];
|
|
20
21
|
saved: Set<number>;
|
|
21
22
|
timer: ReturnType<typeof setTimeout>;
|
|
@@ -32,6 +33,7 @@ export interface MediaCtx {
|
|
|
32
33
|
fromName?: string;
|
|
33
34
|
fromDisplayName?: string;
|
|
34
35
|
reply?: Record<string, string>;
|
|
36
|
+
callMeta?: () => Record<string, string>;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export function capSet(set: Set<string>, max: number): void {
|
|
@@ -72,6 +74,7 @@ export function takeMediaCtx(buf: PendingMsg): MediaCtx {
|
|
|
72
74
|
fromName: buf.fromName,
|
|
73
75
|
fromDisplayName: buf.fromDisplayName,
|
|
74
76
|
reply: buf.reply,
|
|
77
|
+
callMeta: buf.callMeta,
|
|
75
78
|
};
|
|
76
79
|
buf.text = '';
|
|
77
80
|
return ctx;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { CALL_NOTICE, sameCall, type CallNotice, type CallRoute } from '@metro-labs/core/call';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { eventInScope } from '../agents/scope.js';
|
|
4
|
+
import { revokeCallPrompts } from '../approvals/pending.js';
|
|
5
|
+
import { sharedCalls } from '../voice/shared.js';
|
|
6
|
+
import type { McpSession } from './session.js';
|
|
7
|
+
|
|
8
|
+
export const callInScope = (scope: Set<string>, route: CallRoute): boolean =>
|
|
9
|
+
scope.has(route.agentId) && eventInScope(scope, route.line);
|
|
10
|
+
|
|
11
|
+
export function connectCallBridge(current: () => McpSession | undefined): void {
|
|
12
|
+
let owner: McpSession | undefined;
|
|
13
|
+
let route: CallRoute | undefined;
|
|
14
|
+
const available = (wanted: CallRoute): boolean => {
|
|
15
|
+
const session = current();
|
|
16
|
+
return session !== undefined && session.streamAttached && session.server.getClientVersion()?.name === 'metro-sdk-runner' &&
|
|
17
|
+
callInScope(session.scope, wanted) && (route === undefined || owner === session);
|
|
18
|
+
};
|
|
19
|
+
const boundTo = (session: McpSession | undefined, wanted: CallRoute): boolean => session === owner && route !== undefined && sameCall(route, wanted);
|
|
20
|
+
const forgetEnded = (notice: CallNotice): void => {
|
|
21
|
+
if (notice.type === 'ended' && route !== undefined && sameCall(route, notice.route)) {
|
|
22
|
+
owner = undefined;
|
|
23
|
+
route = undefined;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
sharedCalls.connect({
|
|
27
|
+
available,
|
|
28
|
+
notify: (notice: CallNotice): boolean => {
|
|
29
|
+
const session = current();
|
|
30
|
+
const bound = notice.type === 'started' ? available(notice.route) : boundTo(session, notice.route);
|
|
31
|
+
forgetEnded(notice);
|
|
32
|
+
if (session === undefined || !session.streamAttached || !callInScope(session.scope, notice.route) || !bound) return false;
|
|
33
|
+
if (notice.type === 'started') {
|
|
34
|
+
owner = session;
|
|
35
|
+
route = notice.route;
|
|
36
|
+
}
|
|
37
|
+
session.server.notification({ method: CALL_NOTICE, params: { ...notice, meta: { line: notice.route.line } } }).catch((err: unknown) => {
|
|
38
|
+
log.warn({ err: errMsg(err) }, 'sdk-call: notification failed');
|
|
39
|
+
sharedCalls.end(notice.route);
|
|
40
|
+
});
|
|
41
|
+
return true;
|
|
42
|
+
},
|
|
43
|
+
revoked: (ended) => { revokeCallPrompts(ended); },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { callRoute, speechTarget, type CallSource } from '@metro-labs/core/call';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { readPreview } from '../approvals/preview.js';
|
|
4
|
+
import { sharedCalls } from '../voice/shared.js';
|
|
5
|
+
import { agentIdForLine } from '../agents/map.js';
|
|
6
|
+
|
|
7
|
+
function boundCall(value: unknown, inScope: (line: string) => boolean): CallSource | null {
|
|
8
|
+
if (!isRecord(value)) return null;
|
|
9
|
+
const route = callRoute(value.route);
|
|
10
|
+
const sourceId = value.sourceId;
|
|
11
|
+
if (route === null || typeof sourceId !== 'string') return null;
|
|
12
|
+
const source = { route, sourceId };
|
|
13
|
+
return inScope(route.line) && sharedCalls.valid(source) ? source : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function permissionCall(params: { call?: unknown; input_preview: string; tool_name: string }, inScope: (line: string) => boolean): CallSource | null | undefined {
|
|
17
|
+
if (params.call !== undefined) return boundCall(params.call, inScope);
|
|
18
|
+
if (params.tool_name !== 'mcp__metro__send') return undefined;
|
|
19
|
+
const { input } = readPreview(params.input_preview);
|
|
20
|
+
if (!isRecord(input) || input.speech === undefined) return undefined;
|
|
21
|
+
const target = speechTarget(input.speech);
|
|
22
|
+
if (target === null || typeof input.line !== 'string' || !inScope(input.line)) return null;
|
|
23
|
+
const agentId = agentIdForLine(input.line);
|
|
24
|
+
return agentId === undefined ? null : sharedCalls.source(input.line, target, new Set([agentId]));
|
|
25
|
+
}
|
|
@@ -10,6 +10,7 @@ import { errResult, makeCtx, ok, toErr } from './ctx.js';
|
|
|
10
10
|
import { runRead } from './read-tool.js';
|
|
11
11
|
import { allowedAgents, currentIdentity } from './request-identity.js';
|
|
12
12
|
import { str } from '@metro-labs/core/str';
|
|
13
|
+
import { sendSpeech } from './speech-send.js';
|
|
13
14
|
|
|
14
15
|
type Station = NonNullable<ReturnType<typeof stationForLine>>;
|
|
15
16
|
|
|
@@ -143,6 +144,7 @@ function noteSent(m: MessageArgs, messageId: string | undefined): void {
|
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
async function handleSend(m: MessageArgs): Promise<ToolResult> {
|
|
147
|
+
if (m.a.speech !== undefined) return sendSpeech(m.line, m.a);
|
|
146
148
|
const text = m.a.text as string | undefined;
|
|
147
149
|
const replyTo = m.a.reply_to as string | undefined;
|
|
148
150
|
const requested = (m.a.attachments as CanonicalAttachment[] | undefined) ?? [];
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
7
7
|
import { str } from '@metro-labs/core/str';
|
|
8
8
|
import { eventInScope } from '../agents/scope.js';
|
|
9
|
+
import { CALL_NOTICE, CALL_STATE } from '@metro-labs/core/call';
|
|
9
10
|
|
|
10
11
|
const EVENT_STORE_MAX = 500;
|
|
11
12
|
|
|
@@ -14,6 +15,7 @@ interface StoredEvent {
|
|
|
14
15
|
streamId: StreamId;
|
|
15
16
|
message: JSONRPCMessage;
|
|
16
17
|
line: string | undefined;
|
|
18
|
+
replays: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface ScopedReplay {
|
|
@@ -32,6 +34,8 @@ function frameLine(message: JSONRPCMessage): string | undefined {
|
|
|
32
34
|
return str((meta as { line?: unknown }).line) || undefined;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
const isCallNotice = (message: JSONRPCMessage): boolean => 'method' in message && (message.method === CALL_NOTICE || message.method === CALL_STATE);
|
|
38
|
+
|
|
35
39
|
const encodeEventId = (streamId: StreamId, seq: number): EventId =>
|
|
36
40
|
`${streamId}${SEP}${seq}`;
|
|
37
41
|
|
|
@@ -52,11 +56,13 @@ export class BoundedEventStore implements EventStore {
|
|
|
52
56
|
|
|
53
57
|
storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
|
|
54
58
|
const eventId = encodeEventId(streamId, ++this.seq);
|
|
59
|
+
const replays = !isCallNotice(message);
|
|
55
60
|
this.events.push({
|
|
56
61
|
eventId,
|
|
57
62
|
streamId,
|
|
58
|
-
message,
|
|
59
|
-
line: frameLine(message),
|
|
63
|
+
message: replays ? message : { jsonrpc: message.jsonrpc, method: CALL_NOTICE },
|
|
64
|
+
line: replays ? frameLine(message) : undefined,
|
|
65
|
+
replays,
|
|
60
66
|
});
|
|
61
67
|
if (this.events.length > this.max) this.events.shift();
|
|
62
68
|
return Promise.resolve(eventId);
|
|
@@ -79,6 +85,7 @@ export class BoundedEventStore implements EventStore {
|
|
|
79
85
|
if (e.eventId === lastEventId) seen = true;
|
|
80
86
|
continue;
|
|
81
87
|
}
|
|
88
|
+
if (!e.replays) continue;
|
|
82
89
|
if (e.line !== undefined && !eventInScope(scope, e.line)) {
|
|
83
90
|
onWithheld?.(e.eventId, e.line);
|
|
84
91
|
continue;
|